From 80aac6fd024d283087673ca79220e0c3d20d8d1d Mon Sep 17 00:00:00 2001 From: Georgi Hristov Date: Mon, 29 Jun 2026 12:12:47 +0300 Subject: [PATCH] feat/Add optional DebugProbe Server ingestion --- .../Configuration/DebugProbeOptionsTests.cs | 26 +++ .../MiddlewareExecutionFlowTests.cs | 13 +- .../Extensions/DebugProbeExtensions.cs | 2 + .../Ingestion/DebugProbeServerClient.cs | 189 ++++++++++++++++++ .../Middleware/DebugProbeMiddleware.cs | 7 +- .../Options/DebugProbeOptions.cs | 23 +++ .../Options/DebugProbeOptionsValidator.cs | 11 +- DebugProbe.SampleApi/Program.cs | 9 +- DebugProbe.SampleApi/appsettings.json | 8 + 9 files changed, 280 insertions(+), 8 deletions(-) create mode 100644 DebugProbe.AspNetCore/Ingestion/DebugProbeServerClient.cs diff --git a/DebugProbe.AspNetCore.Tests/Configuration/DebugProbeOptionsTests.cs b/DebugProbe.AspNetCore.Tests/Configuration/DebugProbeOptionsTests.cs index d0f08d4..6c86bd5 100644 --- a/DebugProbe.AspNetCore.Tests/Configuration/DebugProbeOptionsTests.cs +++ b/DebugProbe.AspNetCore.Tests/Configuration/DebugProbeOptionsTests.cs @@ -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); @@ -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"]; @@ -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); @@ -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(() => + services.AddDebugProbe(options => + { + options.ServerUrl = "localhost:5000"; + })); + + Assert.Contains("ServerUrl", exception.Message); + } + [Fact] public void MaxBodyCaptureSizeKb_negative_throws_ArgumentOutOfRangeException() { diff --git a/DebugProbe.AspNetCore.Tests/Middleware/MiddlewareExecutionFlowTests.cs b/DebugProbe.AspNetCore.Tests/Middleware/MiddlewareExecutionFlowTests.cs index 730f86a..9c9db79 100644 --- a/DebugProbe.AspNetCore.Tests/Middleware/MiddlewareExecutionFlowTests.cs +++ b/DebugProbe.AspNetCore.Tests/Middleware/MiddlewareExecutionFlowTests.cs @@ -1,4 +1,5 @@ using System.Net; +using DebugProbe.AspNetCore.Ingestion; using DebugProbe.AspNetCore.Middleware; using DebugProbe.AspNetCore.Options; using DebugProbe.AspNetCore.Storage; @@ -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); @@ -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(() => middleware.Invoke(context, store)); diff --git a/DebugProbe.AspNetCore/Extensions/DebugProbeExtensions.cs b/DebugProbe.AspNetCore/Extensions/DebugProbeExtensions.cs index 0fcc2d1..1dad984 100644 --- a/DebugProbe.AspNetCore/Extensions/DebugProbeExtensions.cs +++ b/DebugProbe.AspNetCore/Extensions/DebugProbeExtensions.cs @@ -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; @@ -48,6 +49,7 @@ public static IServiceCollection AddDebugProbe(this IServiceCollection services, services.AddHttpContextAccessor(); services.AddHttpClient(); + services.AddSingleton(); if (options.CaptureOutgoingHttpClientRequests) { diff --git a/DebugProbe.AspNetCore/Ingestion/DebugProbeServerClient.cs b/DebugProbe.AspNetCore/Ingestion/DebugProbeServerClient.cs new file mode 100644 index 0000000..658237e --- /dev/null +++ b/DebugProbe.AspNetCore/Ingestion/DebugProbeServerClient.cs @@ -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 RequestHeaders { get; set; } = []; + public Dictionary ResponseHeaders { get; set; } = []; + public List 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 RequestHeaders { get; set; } = []; + public Dictionary ResponseHeaders { get; set; } = []; + } +} \ No newline at end of file diff --git a/DebugProbe.AspNetCore/Middleware/DebugProbeMiddleware.cs b/DebugProbe.AspNetCore/Middleware/DebugProbeMiddleware.cs index 13a131f..5f3f26a 100644 --- a/DebugProbe.AspNetCore/Middleware/DebugProbeMiddleware.cs +++ b/DebugProbe.AspNetCore/Middleware/DebugProbeMiddleware.cs @@ -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; @@ -41,14 +42,16 @@ public class DebugProbeMiddleware private readonly RequestDelegate _next; private readonly DebugProbeOptions _options; + private readonly DebugProbeServerClient _serverClient; /// /// Initializes a new instance of the middleware. /// - public DebugProbeMiddleware(RequestDelegate next, DebugProbeOptions options) + public DebugProbeMiddleware(RequestDelegate next, DebugProbeOptions options, DebugProbeServerClient serverClient) { _next = next; _options = options; + _serverClient = serverClient; } /// @@ -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); } } diff --git a/DebugProbe.AspNetCore/Options/DebugProbeOptions.cs b/DebugProbe.AspNetCore/Options/DebugProbeOptions.cs index 1753935..20f1fa9 100644 --- a/DebugProbe.AspNetCore/Options/DebugProbeOptions.cs +++ b/DebugProbe.AspNetCore/Options/DebugProbeOptions.cs @@ -52,6 +52,29 @@ public int MaxBodyCaptureSizeKb /// public bool CaptureOutgoingHttpClientRequests { get; set; } = true; + /// + /// Optional DebugProbe Server base URL used to send captured traces to a central location. + /// When not configured, DebugProbe stores traces locally only. + /// + public string? ServerUrl { get; set; } + + /// + /// Optional stable application id sent to DebugProbe Server. + /// Defaults to the entry assembly name when not configured. + /// + public string? ApplicationId { get; set; } + + /// + /// Optional friendly application name sent to DebugProbe Server. + /// Defaults to the entry assembly name when not configured. + /// + public string? ApplicationName { get; set; } + + /// + /// Optional application instance id sent to DebugProbe Server. + /// + public string? InstanceId { get; set; } + /// /// Additional request paths to ignore. /// diff --git a/DebugProbe.AspNetCore/Options/DebugProbeOptionsValidator.cs b/DebugProbe.AspNetCore/Options/DebugProbeOptionsValidator.cs index 008a002..02d722c 100644 --- a/DebugProbe.AspNetCore/Options/DebugProbeOptionsValidator.cs +++ b/DebugProbe.AspNetCore/Options/DebugProbeOptionsValidator.cs @@ -1,4 +1,4 @@ -using Microsoft.Extensions.Options; +using Microsoft.Extensions.Options; namespace DebugProbe.AspNetCore.Options; @@ -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; } } \ No newline at end of file diff --git a/DebugProbe.SampleApi/Program.cs b/DebugProbe.SampleApi/Program.cs index b9fb554..3b74cdf 100644 --- a/DebugProbe.SampleApi/Program.cs +++ b/DebugProbe.SampleApi/Program.cs @@ -10,10 +10,15 @@ builder.Services.AddSwaggerGen(); builder.Services.AddDebugProbe(options => { - options.MaxEntries = 10; - options.AllowUiInProduction = true; + options.MaxEntries = builder.Configuration.GetValue("DebugProbe:MaxEntries"); + options.AllowUiInProduction = builder.Configuration.GetValue("DebugProbe:AllowUiInProduction"); + options.ServerUrl = builder.Configuration["DebugProbe:ServerUrl"]; + options.ApplicationId = builder.Configuration["DebugProbe:ApplicationId"]; + options.ApplicationName = builder.Configuration["DebugProbe:ApplicationName"]; + options.InstanceId = builder.Configuration["DebugProbe:InstanceId"]; }); + var app = builder.Build(); // Configure the HTTP request pipeline. diff --git a/DebugProbe.SampleApi/appsettings.json b/DebugProbe.SampleApi/appsettings.json index 10f68b8..7537188 100644 --- a/DebugProbe.SampleApi/appsettings.json +++ b/DebugProbe.SampleApi/appsettings.json @@ -5,5 +5,13 @@ "Microsoft.AspNetCore": "Warning" } }, + "DebugProbe": { + "MaxEntries": 10, + "AllowUiInProduction": true, + "ServerUrl": "https://localhost:7141/", + "ApplicationId": "debugprobe-sample-api", + "ApplicationName": "DebugProbe Sample API", + "InstanceId": "local" + }, "AllowedHosts": "*" }