Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,10 @@ public void Defaults_work_correctly()
Assert.False(options.AllowUiInProduction);
Assert.Null(options.AuthorizationPolicy);
Assert.True(options.CaptureOutgoingHttpClientRequests);
Assert.Null(options.ServerUrl);
Assert.Null(options.ApplicationId);
Assert.Null(options.ApplicationName);
Assert.Null(options.InstanceId);
Assert.Empty(options.IgnorePaths);
Assert.Equal(["Authorization", "Cookie", "Set-Cookie"], options.RedactedHeaders);
Assert.Empty(options.RedactedQueryParameters);
Expand All @@ -38,6 +42,10 @@ public void Custom_options_are_registered_and_used()
options.AuthorizationPolicy = "DebugProbePolicy";
options.IgnorePaths = ["/health"];
options.CaptureOutgoingHttpClientRequests = false;
options.ServerUrl = "https://debugprobe.example";
options.ApplicationId = "sample-api";
options.ApplicationName = "Sample API";
options.InstanceId = "local-dev";
options.RedactedHeaders = ["X-Api-Key"];
options.RedactedQueryParameters = ["token"];
options.RedactedJsonFields = ["password"];
Expand All @@ -54,6 +62,10 @@ public void Custom_options_are_registered_and_used()
Assert.Equal("DebugProbePolicy", options.AuthorizationPolicy);
Assert.Equal(["/health"], options.IgnorePaths);
Assert.False(options.CaptureOutgoingHttpClientRequests);
Assert.Equal("https://debugprobe.example", options.ServerUrl);
Assert.Equal("sample-api", options.ApplicationId);
Assert.Equal("Sample API", options.ApplicationName);
Assert.Equal("local-dev", options.InstanceId);
Assert.Equal(["X-Api-Key"], options.RedactedHeaders);
Assert.Equal(["token"], options.RedactedQueryParameters);
Assert.Equal(["password"], options.RedactedJsonFields);
Expand Down Expand Up @@ -88,6 +100,20 @@ public void MaxEntries_negative_throws_InvalidOperationException()
Assert.Contains("MaxEntries", exception.Message);
}

[Fact]
public void Invalid_ServerUrl_throws_InvalidOperationException()
{
var services = new ServiceCollection();

var exception = Assert.Throws<InvalidOperationException>(() =>
services.AddDebugProbe(options =>
{
options.ServerUrl = "localhost:5000";
}));

Assert.Contains("ServerUrl", exception.Message);
}

[Fact]
public void MaxBodyCaptureSizeKb_negative_throws_ArgumentOutOfRangeException()
{
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using System.Net;
using DebugProbe.AspNetCore.Ingestion;
using DebugProbe.AspNetCore.Middleware;
using DebugProbe.AspNetCore.Options;
using DebugProbe.AspNetCore.Storage;
Expand Down Expand Up @@ -90,10 +91,12 @@ public async Task Response_stream_is_restored_after_successful_request()
{
var originalBody = new MemoryStream();
var context = CreateHttpContext(originalBody);
var store = new DebugEntryStore(new DebugProbeOptions());
var options = new DebugProbeOptions();
var store = new DebugEntryStore(options);
var middleware = new DebugProbeMiddleware(
async httpContext => await httpContext.Response.WriteAsync("ok"),
new DebugProbeOptions());
options,
new DebugProbeServerClient(options));

await middleware.Invoke(context, store);

Expand All @@ -106,10 +109,12 @@ public async Task Response_stream_is_restored_after_exception()
{
var originalBody = new MemoryStream();
var context = CreateHttpContext(originalBody);
var store = new DebugEntryStore(new DebugProbeOptions());
var options = new DebugProbeOptions();
var store = new DebugEntryStore(options);
var middleware = new DebugProbeMiddleware(
_ => throw new InvalidOperationException("broken"),
new DebugProbeOptions());
options,
new DebugProbeServerClient(options));

await Assert.ThrowsAsync<InvalidOperationException>(() => middleware.Invoke(context, store));

Expand Down
2 changes: 2 additions & 0 deletions DebugProbe.AspNetCore/Extensions/DebugProbeExtensions.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
using System.Net.Http.Json;
using DebugProbe.AspNetCore.Handlers;
using DebugProbe.AspNetCore.Ingestion;
using DebugProbe.AspNetCore.Internal.Compare;
using DebugProbe.AspNetCore.Internal.Rendering;
using DebugProbe.AspNetCore.Internal.Resources;
Expand Down Expand Up @@ -48,6 +49,7 @@ public static IServiceCollection AddDebugProbe(this IServiceCollection services,
services.AddHttpContextAccessor();

services.AddHttpClient();
services.AddSingleton<DebugProbeServerClient>();

if (options.CaptureOutgoingHttpClientRequests)
{
Expand Down
189 changes: 189 additions & 0 deletions DebugProbe.AspNetCore/Ingestion/DebugProbeServerClient.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,189 @@
using System.Net.Http.Json;
using System.Reflection;
using DebugProbe.AspNetCore.Models;
using DebugProbe.AspNetCore.Options;

namespace DebugProbe.AspNetCore.Ingestion;

public sealed class DebugProbeServerClient
{
private const string BodyTooLargeMessage = "[Body too large]";
private const string BinaryBodyMessage = "[Body not captured: non-text content]";

private static readonly HttpClient Http = new()
{
Timeout = TimeSpan.FromSeconds(2)
};

private readonly DebugProbeOptions _options;

public DebugProbeServerClient(DebugProbeOptions options)
{
_options = options;
}

public async Task SendRequestAsync(DebugEntry entry, DebugEnvironment environment)
{
if (!TryGetEndpoint(out var endpoint))
{
return;
}

try
{
await Http.PostAsJsonAsync(endpoint, MapRequest(entry, environment));
}
catch
{
// Central ingestion is optional and must never break the local application.
}
}

private bool TryGetEndpoint(out Uri endpoint)
{
endpoint = default!;

if (string.IsNullOrWhiteSpace(_options.ServerUrl) ||
!Uri.TryCreate(_options.ServerUrl, UriKind.Absolute, out var serverUri))
{
return false;
}

endpoint = new Uri(EnsureTrailingSlash(serverUri), "api/ingestion/requests");
return true;
}

private RequestData MapRequest(DebugEntry entry, DebugEnvironment environment)
{
return new RequestData
{
Application = MapApplication(environment),
TimestampUtc = entry.RequestTimeUtc.ToUniversalTime(),
RequestId = entry.Id,
Method = entry.Method,
Path = entry.Path,
Query = entry.Query,
Url = entry.RequestUrl,
DurationMs = entry.DurationMs,
StatusCode = entry.StatusCode,
RequestBody = MapBody(entry.RequestBody, entry.RequestSize),
ResponseBody = MapBody(entry.ResponseBody, entry.ResponseSize),
RequestHeaders = entry.RequestHeaders,
ResponseHeaders = entry.ResponseHeaders,
OutgoingRequests = entry.OutgoingRequests.Select(MapOutgoingRequest).ToList()
};
}

private ApplicationData MapApplication(DebugEnvironment environment)
{
var assemblyName = Assembly.GetEntryAssembly()?.GetName().Name ?? "Application";

return new ApplicationData
{
ApplicationId = string.IsNullOrWhiteSpace(_options.ApplicationId) ? assemblyName : _options.ApplicationId,
ApplicationName = string.IsNullOrWhiteSpace(_options.ApplicationName) ? assemblyName : _options.ApplicationName,
Environment = environment.Environment,
InstanceId = _options.InstanceId,
MachineName = environment.MachineName,
AssemblyVersion = environment.AssemblyVersion,
Culture = environment.Culture,
UiCulture = environment.UiCulture,
TimeZone = environment.TimeZone
};
}

private static OutgoingRequestData MapOutgoingRequest(DebugOutgoingRequest outgoing)
{
return new OutgoingRequestData
{
Method = outgoing.Method,
Url = outgoing.Url,
StatusCode = outgoing.StatusCode,
DurationMs = outgoing.DurationMs,
TimestampUtc = new DateTimeOffset(DateTime.SpecifyKind(outgoing.TimestampUtc, DateTimeKind.Utc)),
IsSuccessStatusCode = outgoing.IsSuccessStatusCode,
RequestBody = MapBody(outgoing.RequestBody, null),
ResponseBody = MapBody(outgoing.ResponseBody, null),
Exception = outgoing.Exception,
RequestHeaders = outgoing.RequestHeaders,
ResponseHeaders = outgoing.ResponseHeaders
};
}

private static BodyData? MapBody(string? content, long? sizeBytes)
{
if (content is null)
{
return null;
}

return new BodyData
{
SizeBytes = sizeBytes,
Captured = content.Length > 0 && content != BodyTooLargeMessage && content != BinaryBodyMessage,
Truncated = content == BodyTooLargeMessage || content.EndsWith("[truncated]", StringComparison.Ordinal),
Content = content
};
}

private static Uri EnsureTrailingSlash(Uri uri)
{
var value = uri.ToString();
return value.EndsWith("/", StringComparison.Ordinal) ? uri : new Uri(value + "/");
}

private sealed class RequestData
{
public int SchemaVersion { get; set; } = 1;
public ApplicationData Application { get; set; } = new();
public DateTimeOffset TimestampUtc { get; set; }
public string? RequestId { get; set; }
public string Method { get; set; } = string.Empty;
public string Path { get; set; } = string.Empty;
public string? Query { get; set; }
public string? Url { get; set; }
public long DurationMs { get; set; }
public int StatusCode { get; set; }
public BodyData? RequestBody { get; set; }
public BodyData? ResponseBody { get; set; }
public Dictionary<string, string> RequestHeaders { get; set; } = [];
public Dictionary<string, string> ResponseHeaders { get; set; } = [];
public List<OutgoingRequestData> OutgoingRequests { get; set; } = [];
}

private sealed class ApplicationData
{
public string ApplicationId { get; set; } = string.Empty;
public string ApplicationName { get; set; } = string.Empty;
public string? Environment { get; set; }
public string? InstanceId { get; set; }
public string? MachineName { get; set; }
public string? AssemblyVersion { get; set; }
public string? Culture { get; set; }
public string? UiCulture { get; set; }
public string? TimeZone { get; set; }
}

private sealed class BodyData
{
public long? SizeBytes { get; set; }
public bool Captured { get; set; }
public bool Truncated { get; set; }
public string? Content { get; set; }
}

private sealed class OutgoingRequestData
{
public string Method { get; set; } = string.Empty;
public string Url { get; set; } = string.Empty;
public int? StatusCode { get; set; }
public long DurationMs { get; set; }
public DateTimeOffset? TimestampUtc { get; set; }
public bool? IsSuccessStatusCode { get; set; }
public BodyData? RequestBody { get; set; }
public BodyData? ResponseBody { get; set; }
public string? Exception { get; set; }
public Dictionary<string, string> RequestHeaders { get; set; } = [];
public Dictionary<string, string> ResponseHeaders { get; set; } = [];
}
}
7 changes: 6 additions & 1 deletion DebugProbe.AspNetCore/Middleware/DebugProbeMiddleware.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
using System.Diagnostics;
using System.Text;
using DebugProbe.AspNetCore.Ingestion;
using DebugProbe.AspNetCore.Internal.Streams;
using DebugProbe.AspNetCore.Internal.Utils;
using DebugProbe.AspNetCore.Models;
Expand Down Expand Up @@ -41,14 +42,16 @@ public class DebugProbeMiddleware

private readonly RequestDelegate _next;
private readonly DebugProbeOptions _options;
private readonly DebugProbeServerClient _serverClient;

/// <summary>
/// Initializes a new instance of the middleware.
/// </summary>
public DebugProbeMiddleware(RequestDelegate next, DebugProbeOptions options)
public DebugProbeMiddleware(RequestDelegate next, DebugProbeOptions options, DebugProbeServerClient serverClient)
{
_next = next;
_options = options;
_serverClient = serverClient;
}

/// <summary>
Expand Down Expand Up @@ -151,6 +154,8 @@ public async Task Invoke(HttpContext context, DebugEntryStore store)
x => RedactionUtils.RedactHeader(x.Key, x.Value.ToString(), _options));

store.Add(entry);

_ = _serverClient.SendRequestAsync(entry, store.Environment);
}
}

Expand Down
23 changes: 23 additions & 0 deletions DebugProbe.AspNetCore/Options/DebugProbeOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,29 @@ public int MaxBodyCaptureSizeKb
/// </summary>
public bool CaptureOutgoingHttpClientRequests { get; set; } = true;

/// <summary>
/// Optional DebugProbe Server base URL used to send captured traces to a central location.
/// When not configured, DebugProbe stores traces locally only.
/// </summary>
public string? ServerUrl { get; set; }

/// <summary>
/// Optional stable application id sent to DebugProbe Server.
/// Defaults to the entry assembly name when not configured.
/// </summary>
public string? ApplicationId { get; set; }

/// <summary>
/// Optional friendly application name sent to DebugProbe Server.
/// Defaults to the entry assembly name when not configured.
/// </summary>
public string? ApplicationName { get; set; }

/// <summary>
/// Optional application instance id sent to DebugProbe Server.
/// </summary>
public string? InstanceId { get; set; }

/// <summary>
/// Additional request paths to ignore.
/// </summary>
Expand Down
11 changes: 10 additions & 1 deletion DebugProbe.AspNetCore/Options/DebugProbeOptionsValidator.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
using Microsoft.Extensions.Options;
using Microsoft.Extensions.Options;

namespace DebugProbe.AspNetCore.Options;

Expand All @@ -17,6 +17,15 @@ public ValidateOptionsResult Validate(
$"Provided value: {options.MaxEntries}.");
}

if (!string.IsNullOrWhiteSpace(options.ServerUrl) &&
(!Uri.TryCreate(options.ServerUrl, UriKind.Absolute, out var serverUri) ||
(serverUri.Scheme != Uri.UriSchemeHttp && serverUri.Scheme != Uri.UriSchemeHttps)))
{
return ValidateOptionsResult.Fail(
"DebugProbe configuration is invalid. " +
"ServerUrl must be an absolute HTTP or HTTPS URL.");
}

return ValidateOptionsResult.Success;
}
}
Loading
Loading