From 095ed172390f75e3a931807b978345ac1295bc58 Mon Sep 17 00:00:00 2001 From: Georgi Hristov Date: Sat, 23 May 2026 20:07:54 +0300 Subject: [PATCH 1/7] feat(http): add outgoing HttpClient request tracing --- .../Assets/css/debugprobe.css | 59 ++++++++- .../Assets/html/details.html | 8 ++ .../Extensions/DebugProbeExtensions.cs | 40 +++++-- .../Handlers/DebugProbeHttpClientHandler.cs | 87 ++++++++++++++ .../Internal/Rendering/HtmlRenderer.cs | 39 ++++-- .../Middleware/DebugProbeMiddleware.cs | 113 +++++++++++------- DebugProbe.AspNetCore/Models/DebugEntry.cs | 16 ++- .../Models/DebugEnvironment.cs | 7 ++ .../Models/DebugOutgoingRequest.cs | 26 ++++ .../Storage/DebugEntryStore.cs | 2 - .../Controllers/DemoController.cs | 21 +++- 11 files changed, 349 insertions(+), 69 deletions(-) create mode 100644 DebugProbe.AspNetCore/Handlers/DebugProbeHttpClientHandler.cs create mode 100644 DebugProbe.AspNetCore/Models/DebugOutgoingRequest.cs diff --git a/DebugProbe.AspNetCore/Assets/css/debugprobe.css b/DebugProbe.AspNetCore/Assets/css/debugprobe.css index 6fd2089..378aa95 100644 --- a/DebugProbe.AspNetCore/Assets/css/debugprobe.css +++ b/DebugProbe.AspNetCore/Assets/css/debugprobe.css @@ -200,6 +200,59 @@ h4 { font-weight: 600; } +/* ========================= + Outgoing Request +========================= */ +.outgoing-request-item { + padding: 14px 0; + border-bottom: 1px solid #ececec; +} + + .outgoing-request-item:last-child { + border-bottom: none; + } + + .outgoing-request-item:hover { + background: #fafafa; + } + +.outgoing-request-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; +} + +.outgoing-request-main { + display: flex; + align-items: center; + gap: 12px; + min-width: 0; + flex: 1; +} + +.outgoing-request-side { + display: flex; + align-items: center; + gap: 10px; + flex-shrink: 0; +} + +.outgoing-url { + overflow: hidden; + color: #374151; + text-overflow: ellipsis; + white-space: nowrap; + font-size: 13px; + font-family: monospace; +} + +.outgoing-duration { + color: #666; + font-size: 13px; +} + + /* ========================= Table ========================= */ @@ -246,7 +299,7 @@ tbody tr:last-child td { .method-pill { display: inline-flex; - min-width: 54px; + min-width: 60px; justify-content: center; padding: 4px 8px; background: #f3f4f6; @@ -276,6 +329,10 @@ tbody tr:last-child td { border-radius: 8px; } +.outgoing-request-group { + border-left-color: #9b51e0; +} + .request-group { border-left-color: #2f80ed; } diff --git a/DebugProbe.AspNetCore/Assets/html/details.html b/DebugProbe.AspNetCore/Assets/html/details.html index 5a49097..88ac6a6 100644 --- a/DebugProbe.AspNetCore/Assets/html/details.html +++ b/DebugProbe.AspNetCore/Assets/html/details.html @@ -90,6 +90,14 @@

{{method}} {{path}}

+
+
+

Outgoing Requests

+
+ + {{outgoingRequests}} +
+
diff --git a/DebugProbe.AspNetCore/Extensions/DebugProbeExtensions.cs b/DebugProbe.AspNetCore/Extensions/DebugProbeExtensions.cs index 29ac12d..66fe2a8 100644 --- a/DebugProbe.AspNetCore/Extensions/DebugProbeExtensions.cs +++ b/DebugProbe.AspNetCore/Extensions/DebugProbeExtensions.cs @@ -1,4 +1,5 @@ using System.Net.Http.Json; +using DebugProbe.AspNetCore.Handlers; using DebugProbe.AspNetCore.Internal.Compare; using DebugProbe.AspNetCore.Internal.Rendering; using DebugProbe.AspNetCore.Internal.Resources; @@ -10,6 +11,7 @@ using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Http; namespace DebugProbe.AspNetCore.Extensions; @@ -29,11 +31,27 @@ public static class DebugProbeExtensions public static IServiceCollection AddDebugProbe(this IServiceCollection services, Action? configure = null) { var options = new DebugProbeOptions(); + configure?.Invoke(options); services.AddSingleton(options); + services.AddSingleton(); + services.AddHttpContextAccessor(); + + services.AddHttpClient(); + + services.AddTransient(); + + services.ConfigureAll(options => + { + options.HttpMessageHandlerBuilderActions.Add(builder => + { + builder.AdditionalHandlers.Add(builder.Services.GetRequiredService()); + }); + }); + return services; } @@ -54,9 +72,10 @@ public static IApplicationBuilder UseDebugProbe(this IApplicationBuilder app) .ToList(); var html = HtmlRenderer.RenderIndexPage(items); - ctx.Response.ContentType = "text/html"; + await ctx.Response.WriteAsync(html); + }).ExcludeFromDescription(); webApp.MapGet("/debug/{id}", async (HttpContext ctx, string id, DebugEntryStore store) => @@ -74,9 +93,10 @@ public static IApplicationBuilder UseDebugProbe(this IApplicationBuilder app) var prettyResponse = JsonUtils.Format(item.ResponseBody); var html = HtmlRenderer.RenderDetailsPage(item, store.Environment, prettyRequest, prettyResponse); - ctx.Response.ContentType = "text/html"; + await ctx.Response.WriteAsync(html); + }).ExcludeFromDescription(); webApp.MapGet("/debug/compare/{id}", async (string id, string baseUrl, string remoteTraceId, @@ -85,6 +105,7 @@ public static IApplicationBuilder UseDebugProbe(this IApplicationBuilder app) { var localEnvironment = store.Environment; var localEntry = store.Get(id); + if (localEntry is null) { return Results.NotFound("Local trace not found"); @@ -102,11 +123,9 @@ public static IApplicationBuilder UseDebugProbe(this IApplicationBuilder app) return Results.BadRequest(validation.Error); } - var remoteEnvironmentUrl = - new Uri(validation.BaseUri!, "/debug/environment"); + var remoteEnvironmentUrl = new Uri(validation.BaseUri!, "/debug/environment"); - var remoteEntryUrl = - new Uri(validation.BaseUri!, $"/debug/json/{remoteTraceId}"); + var remoteEntryUrl = new Uri(validation.BaseUri!, $"/debug/json/{remoteTraceId}"); DebugEntry? remoteEntry; DebugEnvironment? remoteEnvironment; @@ -131,7 +150,6 @@ public static IApplicationBuilder UseDebugProbe(this IApplicationBuilder app) { return Results.BadRequest("Failed to reach remote server"); } - var diff = DebugEntryComparer.Compare(localEntry, remoteEntry); @@ -151,20 +169,23 @@ public static IApplicationBuilder UseDebugProbe(this IApplicationBuilder app) culture = new { local = localEnvironment.Culture, remote = remoteEnvironment?.Culture ?? "" }, requestBody = new { local = localEntry.RequestBody ?? "", remote = remoteEntry.RequestBody ?? "" }, responseBody = new { local = localEntry.ResponseBody ?? "", remote = remoteEntry.ResponseBody ?? "" }, - diffs = diff }); + }).ExcludeFromDescription(); webApp.MapGet("/debug/environment", (DebugEntryStore store) => { return Results.Ok(store.Environment); + }).ExcludeFromDescription(); webApp.MapGet("/debug/json/{id}", (string id, DebugEntryStore store) => { var item = store.Get(id); + return item is null ? Results.NotFound() : Results.Json(item); + }).ExcludeFromDescription(); @@ -176,12 +197,15 @@ public static IApplicationBuilder UseDebugProbe(this IApplicationBuilder app) } return Results.Text(content, "application/javascript"); + }).ExcludeFromDescription(); webApp.MapPost("/debug/clear", (DebugEntryStore store) => { store.Clear(); + return Results.Ok(); + }).ExcludeFromDescription(); webApp.Map("/debug/logo.png", ctx => diff --git a/DebugProbe.AspNetCore/Handlers/DebugProbeHttpClientHandler.cs b/DebugProbe.AspNetCore/Handlers/DebugProbeHttpClientHandler.cs new file mode 100644 index 0000000..f7eddb4 --- /dev/null +++ b/DebugProbe.AspNetCore/Handlers/DebugProbeHttpClientHandler.cs @@ -0,0 +1,87 @@ +using System.Diagnostics; +using DebugProbe.AspNetCore.Models; +using Microsoft.AspNetCore.Http; + +namespace DebugProbe.AspNetCore.Handlers; + +public class DebugProbeHttpClientHandler : DelegatingHandler +{ + private static readonly HashSet SensitiveHeaders = + [ + "Authorization", + "Cookie", + "Set-Cookie" + ]; + + private readonly IHttpContextAccessor _httpContextAccessor; + + public DebugProbeHttpClientHandler(IHttpContextAccessor httpContextAccessor) + { + _httpContextAccessor = httpContextAccessor; + } + + protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + var started = Stopwatch.StartNew(); + + try + { + var response = await base.SendAsync(request, cancellationToken); + + CaptureRequest(request, response, null, started.ElapsedMilliseconds); + + return response; + } + catch (Exception ex) + { + CaptureRequest( + request, + null, + ex, + started.ElapsedMilliseconds); + + throw; + } + } + + private void CaptureRequest(HttpRequestMessage request, HttpResponseMessage? response, Exception? exception, long durationMs) + { + var context = _httpContextAccessor.HttpContext; + + if (context == null) + { + return; + } + + if (!context.Items.TryGetValue("DebugProbeEntry", out var value)) + { + return; + } + + if (value is not DebugEntry entry) + { + return; + } + + entry.OutgoingRequests.Add(new DebugOutgoingRequest + { + Method = request.Method.Method, + + Url = request.RequestUri?.ToString() ?? string.Empty, + + StatusCode = response != null ? (int)response.StatusCode : null, + + DurationMs = durationMs, + + Exception = exception?.ToString(), + + TimestampUtc = DateTime.UtcNow, + + IsSuccessStatusCode = response?.IsSuccessStatusCode ?? false, + + RequestHeaders = request.Headers.ToDictionary(x => x.Key, x => SensitiveHeaders.Contains(x.Key) ? "[REDACTED]" : string.Join(", ", x.Value)), + + ResponseHeaders = response != null ? response.Headers.ToDictionary(x => x.Key, x => SensitiveHeaders.Contains(x.Key) ? "[REDACTED]" : string.Join(", ", x.Value)) : [] + }); + } +} \ No newline at end of file diff --git a/DebugProbe.AspNetCore/Internal/Rendering/HtmlRenderer.cs b/DebugProbe.AspNetCore/Internal/Rendering/HtmlRenderer.cs index 03c6525..c77ddc2 100644 --- a/DebugProbe.AspNetCore/Internal/Rendering/HtmlRenderer.cs +++ b/DebugProbe.AspNetCore/Internal/Rendering/HtmlRenderer.cs @@ -71,18 +71,36 @@ public static string RenderIndexPage(List items) public static string RenderDetailsPage(DebugEntry x, DebugEnvironment e, string req, string res) { - //var headers = string.Join("", x.Reqe.Select(h => - // $"{Encode(h.Key)}{Encode(h.Value)}")); - var requestHeaders = string.Join(Environment.NewLine, x.RequestHeaders.Select(h => $"{h.Key}: {h.Value}")); var responseHeaders = string.Join(Environment.NewLine, x.ResponseHeaders.Select(h => $"{h.Key}: {h.Value}")); - var pathWithQuery = string.IsNullOrEmpty(x.Query) - ? x.Path - : $"{x.Path}{x.Query}"; + var pathWithQuery = string.IsNullOrEmpty(x.Query) ? x.Path : $"{x.Path}{x.Query}"; var statusClass = GetStatusClass(x.StatusCode); + var outgoingRequests = string.Join("", x.OutgoingRequests.Select(r => $@" +
+
+
+ + {Encode(r.Method)} + + + {Encode(r.Url)} + +
+
+ + {(r.StatusCode.HasValue ? GetStatusText(r.StatusCode.Value) : "Failed")} + + + {r.DurationMs} ms + +
+
+
+ ")); + var content = EmbeddedResources.Details .Replace("{{method}}", Encode(x.Method)) .Replace("{{path}}", Encode(pathWithQuery)) @@ -108,14 +126,19 @@ public static string RenderDetailsPage(DebugEntry x, DebugEnvironment e, string .Replace("{{dateFormat}}", e.DateFormat ?? "") .Replace("{{assemblyVersion}}", Encode(e.AssemblyVersion)) + .Replace("{{outgoingRequests}}", + string.IsNullOrWhiteSpace(outgoingRequests) + ? "
No outgoing requests
" + : outgoingRequests) + .Replace("{{requestUrl}}", Encode(string.IsNullOrEmpty(x.RequestUrl) ? "" : x.RequestUrl)) .Replace("{{requestHeaders}}", Encode(requestHeaders)) .Replace("{{request}}", Encode(string.IsNullOrEmpty(req) ? "" : req)) .Replace("{{responseHeaders}}", Encode(responseHeaders)) - .Replace("{{response}}", Encode(string.IsNullOrEmpty(res) ? "" : res)); + .Replace("{{response}}", Encode(string.IsNullOrEmpty(res) ? "" : res) - //.Replace("{{headers}}", headers); + ); return BuildLayout(content); } diff --git a/DebugProbe.AspNetCore/Middleware/DebugProbeMiddleware.cs b/DebugProbe.AspNetCore/Middleware/DebugProbeMiddleware.cs index 04a7a6f..c547901 100644 --- a/DebugProbe.AspNetCore/Middleware/DebugProbeMiddleware.cs +++ b/DebugProbe.AspNetCore/Middleware/DebugProbeMiddleware.cs @@ -16,6 +16,7 @@ public class DebugProbeMiddleware { private const string BodyTooLargeMessage = "[Body too large]"; private const string BinaryBodyMessage = "[Body not captured: non-text content]"; + private static readonly string[] DefaultIgnorePaths = [ "/debug", @@ -77,9 +78,20 @@ public async Task Invoke(HttpContext context, DebugEntryStore store) var requestBody = await CaptureRequestBodyAsync(context, maxBodySize); var originalBody = context.Response.Body; - await using var responseCapture = new BoundedResponseCaptureStream(originalBody, maxBodySize + 1); + + await using var responseCapture = + new BoundedResponseCaptureStream(originalBody, maxBodySize + 1); + context.Response.Body = responseCapture; + var entry = new DebugEntry + { + Id = Guid.NewGuid().ToString(), + Timestamp = DateTime.UtcNow + }; + + context.Items["DebugProbeEntry"] = entry; + var started = Stopwatch.StartNew(); var exception = false; @@ -98,49 +110,50 @@ public async Task Invoke(HttpContext context, DebugEntryStore store) finally { started.Stop(); - var durationMs = started.ElapsedTicks > 0 - ? Math.Max(1, started.ElapsedMilliseconds) - : 0; + + var durationMs = started.ElapsedTicks > 0 ? Math.Max(1, started.ElapsedMilliseconds) : 0; context.Response.Body = originalBody; var statusCode = exception && context.Response.StatusCode == 200 ? 500 : context.Response.StatusCode; + var responseBody = exception ? Trim(exceptionResponseBody, maxBodySize) : CaptureResponseBody(context, responseCapture, maxBodySize); - store.Add(new DebugEntry - { - Id = Guid.NewGuid().ToString(), - - // Overview - Method = context.Request.Method, - Path = context.Request.Path, - Query = context.Request.QueryString.ToString(), - StatusCode = statusCode, - RequestTimeUtc = DateTime.UtcNow, - DurationMs = durationMs, - RequestSize = context.Request.ContentLength ?? Encoding.UTF8.GetByteCount(requestBody), - ResponseSize = Encoding.UTF8.GetByteCount(responseBody), - - - // Request Headers - RequestHeaders = context.Request.Headers.ToDictionary(x => x.Key, x => - SensitiveHeaders.Contains(x.Key) ? "[REDACTED]" : x.Value.ToString()), - - // Request - RequestUrl = $"{context.Request.Scheme}://{context.Request.Host}" + - $"{context.Request.Path}{context.Request.QueryString}", - RequestBody = Trim(requestBody, maxBodySize), - - // Response - ResponseBody = Trim(responseBody, maxBodySize), - - // Response Headers - ResponseHeaders = context.Response.Headers.ToDictionary(x => x.Key, x => - SensitiveHeaders.Contains(x.Key)? "[REDACTED]" : x.Value.ToString()), - - // Other - Timestamp = DateTime.UtcNow, - }); + entry.Method = context.Request.Method; + + entry.Path = context.Request.Path; + + entry.Query = context.Request.QueryString.ToString(); + + entry.StatusCode = statusCode; + + entry.RequestTimeUtc = DateTime.UtcNow; + + entry.DurationMs = durationMs; + + entry.RequestSize = context.Request.ContentLength ?? Encoding.UTF8.GetByteCount(requestBody); + + entry.ResponseSize = Encoding.UTF8.GetByteCount(responseBody); + + entry.RequestHeaders = + context.Request.Headers.ToDictionary( + x => x.Key, + x => SensitiveHeaders.Contains(x.Key) ? "[REDACTED]" : x.Value.ToString()); + + entry.RequestUrl = + $"{context.Request.Scheme}://{context.Request.Host}" + + $"{context.Request.Path}{context.Request.QueryString}"; + + entry.RequestBody = Trim(requestBody, maxBodySize); + + entry.ResponseBody = Trim(responseBody, maxBodySize); + + entry.ResponseHeaders = + context.Response.Headers.ToDictionary( + x => x.Key, + x => SensitiveHeaders.Contains(x.Key) ? "[REDACTED]" : x.Value.ToString()); + + store.Add(entry); } } @@ -169,12 +182,12 @@ private static async Task CaptureRequestBodyAsync(HttpContext context, i } context.Request.Body.Position = 0; + var bytes = await ReadAtMostAsync(context.Request.Body, maxBodySize + 1); + context.Request.Body.Position = 0; - return bytes.Length > maxBodySize - ? BodyTooLargeMessage - : Encoding.UTF8.GetString(bytes); + return bytes.Length > maxBodySize ? BodyTooLargeMessage : Encoding.UTF8.GetString(bytes); } private static string CaptureResponseBody(HttpContext context, BoundedResponseCaptureStream responseCapture, int maxBodySize) @@ -229,12 +242,15 @@ private static bool IsTextContent(string? contentType) private static async Task ReadAtMostAsync(Stream stream, int byteLimit) { using var buffer = new MemoryStream(); + var remaining = byteLimit; + var chunk = new byte[Math.Min(81920, byteLimit)]; while (remaining > 0) { - var read = await stream.ReadAsync(chunk.AsMemory(0, Math.Min(chunk.Length, remaining))); + var read = await stream.ReadAsync( + chunk.AsMemory(0, Math.Min(chunk.Length, remaining))); if (read == 0) { @@ -242,6 +258,7 @@ private static async Task ReadAtMostAsync(Stream stream, int byteLimit) } await buffer.WriteAsync(chunk.AsMemory(0, read)); + remaining -= read; } @@ -250,7 +267,13 @@ private static async Task ReadAtMostAsync(Stream stream, int byteLimit) private static string Trim(string? value, int max = 2000) { - if (string.IsNullOrEmpty(value)) return value ?? string.Empty; - return value.Length <= max ? value : value.Substring(0, max); + if (string.IsNullOrEmpty(value)) + { + return value ?? string.Empty; + } + + return value.Length <= max + ? value + : value.Substring(0, max); } -} +} \ No newline at end of file diff --git a/DebugProbe.AspNetCore/Models/DebugEntry.cs b/DebugProbe.AspNetCore/Models/DebugEntry.cs index e6eb787..2abed63 100644 --- a/DebugProbe.AspNetCore/Models/DebugEntry.cs +++ b/DebugProbe.AspNetCore/Models/DebugEntry.cs @@ -4,25 +4,33 @@ public class DebugEntry { public string Id { get; set; } = default!; - // Request public string Path { get; set; } = default!; + public string Method { get; set; } = default!; + public string? Query { get; set; } + public string? RequestUrl { get; set; } + public string RequestBody { get; set; } = default!; + public DateTimeOffset RequestTimeUtc { get; set; } + public long RequestSize { get; set; } + public long DurationMs { get; set; } - // Response public int StatusCode { get; set; } + public long ResponseSize { get; set; } + public string ResponseBody { get; set; } = default!; - // Headers public Dictionary RequestHeaders { get; set; } = new(); + public Dictionary ResponseHeaders { get; set; } = new(); - // Metadata public DateTimeOffset Timestamp { get; set; } + + public List OutgoingRequests { get; set; } = []; } diff --git a/DebugProbe.AspNetCore/Models/DebugEnvironment.cs b/DebugProbe.AspNetCore/Models/DebugEnvironment.cs index bd2d762..c68d322 100644 --- a/DebugProbe.AspNetCore/Models/DebugEnvironment.cs +++ b/DebugProbe.AspNetCore/Models/DebugEnvironment.cs @@ -3,11 +3,18 @@ public class DebugEnvironment { public string Environment { get; init; } = default!; + public string Culture { get; init; } = default!; + public string? UiCulture { get; init; } + public string? MachineName { get; init; } + public string? AssemblyVersion { get; init; } + public string? TimeZone { get; init; } + public string? DecimalSeparator { get; init; } + public string? DateFormat { get; init; } } diff --git a/DebugProbe.AspNetCore/Models/DebugOutgoingRequest.cs b/DebugProbe.AspNetCore/Models/DebugOutgoingRequest.cs new file mode 100644 index 0000000..639c4bb --- /dev/null +++ b/DebugProbe.AspNetCore/Models/DebugOutgoingRequest.cs @@ -0,0 +1,26 @@ +namespace DebugProbe.AspNetCore.Models; + +public class DebugOutgoingRequest +{ + public string Method { get; set; } = default!; + + public string Url { get; set; } = default!; + + public int? StatusCode { get; set; } + + public long DurationMs { get; set; } + + public string? RequestBody { get; set; } + + public string? ResponseBody { get; set; } + + public string? Exception { get; set; } + + public Dictionary RequestHeaders { get; set; } = []; + + public Dictionary ResponseHeaders { get; set; } = []; + + public DateTime TimestampUtc { get; set; } + + public bool IsSuccessStatusCode { get; set; } +} diff --git a/DebugProbe.AspNetCore/Storage/DebugEntryStore.cs b/DebugProbe.AspNetCore/Storage/DebugEntryStore.cs index 0aed7f8..caf9627 100644 --- a/DebugProbe.AspNetCore/Storage/DebugEntryStore.cs +++ b/DebugProbe.AspNetCore/Storage/DebugEntryStore.cs @@ -2,7 +2,6 @@ using System.Globalization; using System.Reflection; using DebugProbe.AspNetCore.Internal.Utils; -using DebugProbe.AspNetCore.Middleware; using DebugProbe.AspNetCore.Models; using DebugProbe.AspNetCore.Options; @@ -21,7 +20,6 @@ public class DebugEntryStore private readonly ConcurrentQueue _queue = new(); private readonly int _limit; - public DebugEntryStore(DebugProbeOptions options) { _limit = options.MaxEntries; diff --git a/DebugProbe.SampleApi/Controllers/DemoController.cs b/DebugProbe.SampleApi/Controllers/DemoController.cs index 68acc74..7627fd3 100644 --- a/DebugProbe.SampleApi/Controllers/DemoController.cs +++ b/DebugProbe.SampleApi/Controllers/DemoController.cs @@ -7,11 +7,30 @@ namespace DebugProbe.SampleApi.Controllers [Route("[controller]")] public class DemoController : ControllerBase { + private readonly IHttpClientFactory _httpClientFactory; private readonly ILogger _logger; - public DemoController(ILogger logger) + public DemoController(ILogger logger, IHttpClientFactory httpClientFactory) { _logger = logger; + _httpClientFactory = httpClientFactory; + } + + [HttpGet("CallExternalApi")] + public async Task CallExternalApi() + { + var client = _httpClientFactory.CreateClient(); + + var response = await client.GetAsync( + "https://jsonplaceholder.typicode.com/posts/1"); + + var content = await response.Content.ReadAsStringAsync(); + + return Ok(new + { + success = response.IsSuccessStatusCode, + data = content + }); } [HttpGet("GetUsers/{count}")] From 8cff12157e97b6be77e6cf007c16df07ac2998bb Mon Sep 17 00:00:00 2001 From: Georgi Hristov Date: Sat, 23 May 2026 20:30:54 +0300 Subject: [PATCH 2/7] feat(http): capture outgoing request and response payloads --- .../Handlers/DebugProbeHttpClientHandler.cs | 41 +++++++++++++++---- .../Internal/Utils/HttpContentUtils.cs | 29 +++++++++++++ .../Middleware/DebugProbeMiddleware.cs | 38 ++++------------- .../Controllers/DemoController.cs | 23 ++++++++++- 4 files changed, 89 insertions(+), 42 deletions(-) create mode 100644 DebugProbe.AspNetCore/Internal/Utils/HttpContentUtils.cs diff --git a/DebugProbe.AspNetCore/Handlers/DebugProbeHttpClientHandler.cs b/DebugProbe.AspNetCore/Handlers/DebugProbeHttpClientHandler.cs index f7eddb4..79ac2db 100644 --- a/DebugProbe.AspNetCore/Handlers/DebugProbeHttpClientHandler.cs +++ b/DebugProbe.AspNetCore/Handlers/DebugProbeHttpClientHandler.cs @@ -1,4 +1,5 @@ using System.Diagnostics; +using DebugProbe.AspNetCore.Internal.Utils; using DebugProbe.AspNetCore.Models; using Microsoft.AspNetCore.Http; @@ -28,23 +29,19 @@ protected override async Task SendAsync(HttpRequestMessage { var response = await base.SendAsync(request, cancellationToken); - CaptureRequest(request, response, null, started.ElapsedMilliseconds); + await CaptureRequest(request, response, null, started.ElapsedMilliseconds); return response; } catch (Exception ex) { - CaptureRequest( - request, - null, - ex, - started.ElapsedMilliseconds); + await CaptureRequest(request, null, ex, started.ElapsedMilliseconds); throw; } } - private void CaptureRequest(HttpRequestMessage request, HttpResponseMessage? response, Exception? exception, long durationMs) + private async Task CaptureRequest(HttpRequestMessage request, HttpResponseMessage? response, Exception? exception,long durationMs) { var context = _httpContextAccessor.HttpContext; @@ -63,7 +60,7 @@ private void CaptureRequest(HttpRequestMessage request, HttpResponseMessage? res return; } - entry.OutgoingRequests.Add(new DebugOutgoingRequest + var outgoing = new DebugOutgoingRequest { Method = request.Method.Method, @@ -82,6 +79,32 @@ private void CaptureRequest(HttpRequestMessage request, HttpResponseMessage? res RequestHeaders = request.Headers.ToDictionary(x => x.Key, x => SensitiveHeaders.Contains(x.Key) ? "[REDACTED]" : string.Join(", ", x.Value)), ResponseHeaders = response != null ? response.Headers.ToDictionary(x => x.Key, x => SensitiveHeaders.Contains(x.Key) ? "[REDACTED]" : string.Join(", ", x.Value)) : [] - }); + }; + + if (request.Content != null) + { + var contentType = request.Content.Headers.ContentType?.MediaType; + + if (HttpContentUtils.IsTextContent(contentType)) + { + var body = await request.Content.ReadAsStringAsync(); + + outgoing.RequestBody = JsonUtils.Format(HttpContentUtils.Trim(body, 2000)); + } + } + + if (response?.Content != null) + { + var contentType = response.Content.Headers.ContentType?.MediaType; + + if (HttpContentUtils.IsTextContent(contentType)) + { + var body = await response.Content.ReadAsStringAsync(); + + outgoing.ResponseBody = JsonUtils.Format(HttpContentUtils.Trim(body, 2000)); + } + } + + entry.OutgoingRequests.Add(outgoing); } } \ No newline at end of file diff --git a/DebugProbe.AspNetCore/Internal/Utils/HttpContentUtils.cs b/DebugProbe.AspNetCore/Internal/Utils/HttpContentUtils.cs new file mode 100644 index 0000000..f120dda --- /dev/null +++ b/DebugProbe.AspNetCore/Internal/Utils/HttpContentUtils.cs @@ -0,0 +1,29 @@ +namespace DebugProbe.AspNetCore.Internal.Utils; + +internal static class HttpContentUtils +{ + public static bool IsTextContent(string? contentType) + { + if (string.IsNullOrWhiteSpace(contentType)) + { + return false; + } + + return contentType.Contains("json", StringComparison.OrdinalIgnoreCase) || + contentType.Contains("xml", StringComparison.OrdinalIgnoreCase) || + contentType.Contains("text", StringComparison.OrdinalIgnoreCase) || + contentType.Contains("javascript", StringComparison.OrdinalIgnoreCase) || + contentType.Contains("html", StringComparison.OrdinalIgnoreCase) || + contentType.Contains("x-www-form-urlencoded", StringComparison.OrdinalIgnoreCase); + } + + public static string Trim(string? value, int max = 2000) + { + if (string.IsNullOrEmpty(value)) + { + return value ?? string.Empty; + } + + return value.Length <= max? value : value.Substring(0, max); + } +} diff --git a/DebugProbe.AspNetCore/Middleware/DebugProbeMiddleware.cs b/DebugProbe.AspNetCore/Middleware/DebugProbeMiddleware.cs index c547901..af00c62 100644 --- a/DebugProbe.AspNetCore/Middleware/DebugProbeMiddleware.cs +++ b/DebugProbe.AspNetCore/Middleware/DebugProbeMiddleware.cs @@ -1,6 +1,7 @@ using System.Diagnostics; using System.Text; using DebugProbe.AspNetCore.Internal.Streams; +using DebugProbe.AspNetCore.Internal.Utils; using DebugProbe.AspNetCore.Models; using DebugProbe.AspNetCore.Options; using DebugProbe.AspNetCore.Storage; @@ -117,7 +118,7 @@ public async Task Invoke(HttpContext context, DebugEntryStore store) var statusCode = exception && context.Response.StatusCode == 200 ? 500 : context.Response.StatusCode; - var responseBody = exception ? Trim(exceptionResponseBody, maxBodySize) : CaptureResponseBody(context, responseCapture, maxBodySize); + var responseBody = exception ? HttpContentUtils.Trim(exceptionResponseBody, maxBodySize) : CaptureResponseBody(context, responseCapture, maxBodySize); entry.Method = context.Request.Method; @@ -144,9 +145,9 @@ public async Task Invoke(HttpContext context, DebugEntryStore store) $"{context.Request.Scheme}://{context.Request.Host}" + $"{context.Request.Path}{context.Request.QueryString}"; - entry.RequestBody = Trim(requestBody, maxBodySize); + entry.RequestBody = HttpContentUtils.Trim(requestBody, maxBodySize); - entry.ResponseBody = Trim(responseBody, maxBodySize); + entry.ResponseBody = HttpContentUtils.Trim(responseBody, maxBodySize); entry.ResponseHeaders = context.Response.Headers.ToDictionary( @@ -164,7 +165,7 @@ private static async Task CaptureRequestBodyAsync(HttpContext context, i return string.Empty; } - if (!IsTextContent(context.Request.ContentType)) + if (!HttpContentUtils.IsTextContent(context.Request.ContentType)) { return BinaryBodyMessage; } @@ -192,7 +193,7 @@ private static async Task CaptureRequestBodyAsync(HttpContext context, i private static string CaptureResponseBody(HttpContext context, BoundedResponseCaptureStream responseCapture, int maxBodySize) { - if (!IsTextContent(context.Response.ContentType)) + if (!HttpContentUtils.IsTextContent(context.Response.ContentType)) { return responseCapture.TotalBytesWritten == 0 ? string.Empty @@ -224,20 +225,7 @@ private static bool HasBody(HttpRequest request) string.Equals(request.Method, HttpMethods.Patch, StringComparison.OrdinalIgnoreCase); } - private static bool IsTextContent(string? contentType) - { - if (string.IsNullOrWhiteSpace(contentType)) - { - return false; - } - - return contentType.Contains("json", StringComparison.OrdinalIgnoreCase) || - contentType.Contains("xml", StringComparison.OrdinalIgnoreCase) || - contentType.Contains("text", StringComparison.OrdinalIgnoreCase) || - contentType.Contains("javascript", StringComparison.OrdinalIgnoreCase) || - contentType.Contains("html", StringComparison.OrdinalIgnoreCase) || - contentType.Contains("x-www-form-urlencoded", StringComparison.OrdinalIgnoreCase); - } + private static async Task ReadAtMostAsync(Stream stream, int byteLimit) { @@ -264,16 +252,4 @@ private static async Task ReadAtMostAsync(Stream stream, int byteLimit) return buffer.ToArray(); } - - private static string Trim(string? value, int max = 2000) - { - if (string.IsNullOrEmpty(value)) - { - return value ?? string.Empty; - } - - return value.Length <= max - ? value - : value.Substring(0, max); - } } \ No newline at end of file diff --git a/DebugProbe.SampleApi/Controllers/DemoController.cs b/DebugProbe.SampleApi/Controllers/DemoController.cs index 7627fd3..ffd7b7b 100644 --- a/DebugProbe.SampleApi/Controllers/DemoController.cs +++ b/DebugProbe.SampleApi/Controllers/DemoController.cs @@ -16,8 +16,27 @@ public DemoController(ILogger logger, IHttpClientFactory httpCli _httpClientFactory = httpClientFactory; } - [HttpGet("CallExternalApi")] - public async Task CallExternalApi() + [HttpPost("CallExternalPost")] + public async Task CallExternalPost() + { + var client = _httpClientFactory.CreateClient(); + + var response = await client.PostAsJsonAsync( + "https://jsonplaceholder.typicode.com/posts", + new + { + title = "DebugProbe", + body = "Tracing test", + userId = 1 + }); + + var content = await response.Content.ReadAsStringAsync(); + + return Ok(content); + } + + [HttpGet("CallExternalGet")] + public async Task CallExternalGet() { var client = _httpClientFactory.CreateClient(); From d6ac9f6b8c1437165a2797f7bb1fe304f21bb221 Mon Sep 17 00:00:00 2001 From: Georgi Hristov Date: Sat, 23 May 2026 23:13:35 +0300 Subject: [PATCH 3/7] feat(ui): improve request trace flow visualization --- .../Assets/css/debugprobe.css | 414 +++++++++++++----- .../Assets/html/details.html | 116 ++--- .../Internal/Rendering/HtmlRenderer.cs | 226 ++++++++-- .../Controllers/DemoController.cs | 28 ++ 4 files changed, 548 insertions(+), 236 deletions(-) diff --git a/DebugProbe.AspNetCore/Assets/css/debugprobe.css b/DebugProbe.AspNetCore/Assets/css/debugprobe.css index 378aa95..43ba66b 100644 --- a/DebugProbe.AspNetCore/Assets/css/debugprobe.css +++ b/DebugProbe.AspNetCore/Assets/css/debugprobe.css @@ -5,7 +5,8 @@ body { margin: 0; background: #f7f7f7; - font-family: Arial, sans-serif; + color: #1f2937; + font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; } a { @@ -37,7 +38,7 @@ h4 { ========================= */ .container { - padding: 20px; + padding: 18px 20px 28px; } .toolbar, @@ -157,13 +158,12 @@ h4 { /* ========================= Details ========================= */ - .details-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); align-items: start; - gap: 16px; - margin-bottom: 24px; + gap: 12px; + margin-bottom: 18px; } .details-card { @@ -174,84 +174,208 @@ h4 { } .details-card-title { - padding: 14px 16px; + padding: 9px 14px; background: #fafafa; border-bottom: 1px solid #eee; - font-size: 16px; + color: #4b5563; + font-size: 13px; font-weight: 600; + text-transform: uppercase; +} + +.details-card-title-split, +.details-card-heading { + display: flex; + align-items: center; +} + +.details-card-title-split { + justify-content: space-between; + gap: 12px; + min-height: 34px; +} + +.details-card-heading { + min-width: 0; + gap: 10px; +} + +.details-card-title-split > strong, +.details-card-heading strong { + color: #111827; + text-transform: none; } .details-item { - min-height: 32px; - padding: 10px 16px; + min-height: 28px; + padding: 8px 14px; border-bottom: 1px solid #f3f3f3; + gap: 14px; } - .details-item:last-child { - border-bottom: none; - } +.details-item:last-child { + border-bottom: none; +} - .details-item span { - color: #666; - } +.details-item span { + color: #666; + font-size: 13px; +} - .details-item strong { - font-size: 15px; - font-weight: 600; - } +.details-item strong { + font-size: 13px; + font-weight: 600; +} /* ========================= - Outgoing Request + Trace Details ========================= */ -.outgoing-request-item { - padding: 14px 0; - border-bottom: 1px solid #ececec; +.trace-card-header, +.trace-card-title, +.trace-card-meta { + display: flex; + align-items: center; } - .outgoing-request-item:last-child { - border-bottom: none; - } +.trace-flow { + position: relative; + display: grid; + gap: 8px; + margin-top: 14px; +} - .outgoing-request-item:hover { - background: #fafafa; - } +.trace-group-label { + margin-left: 30px; + margin-bottom: -8px; + font-size: 12px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.08em; + color: #8b8b95; +} -.outgoing-request-header { - display: flex; - align-items: center; - justify-content: space-between; - gap: 16px; +.trace-card { + min-width: 0; } -.outgoing-request-main { - display: flex; - align-items: center; - gap: 12px; +.trace-dot { + flex: 0 0 10px; + width: 10px; + height: 10px; + background: #2f80ed; + border-radius: 50%; +} + +.trace-card.dependency .trace-dot { + background: #9b51e0; +} + +.trace-card.response .trace-dot { + background: #27ae60; +} + +.compare-card .trace-dot { + background: #f39c12; +} + +.trace-card.error .trace-dot, +.trace-card.response.error .trace-dot, +.trace-card.dependency.error .trace-dot { + background: #e74c3c; +} + +.trace-card-main { min-width: 0; - flex: 1; + background: #fff; + border: 1px solid #e5e7eb; + border-radius: 8px; + box-shadow: 0 1px 1px rgba(15, 23, 42, 0.03); } -.outgoing-request-side { - display: flex; - align-items: center; - gap: 10px; - flex-shrink: 0; +.trace-card-main:hover { + border-color: #d1d5db; +} + +.trace-card-header { + justify-content: space-between; + gap: 12px; + padding: 10px 12px; +} + +.trace-card-title { + min-width: 0; + gap: 8px; } -.outgoing-url { +.trace-card-title strong { overflow: hidden; - color: #374151; + color: #111827; text-overflow: ellipsis; white-space: nowrap; + font-family: ui-monospace, SFMono-Regular, Consolas, "Liberation Mono", monospace; font-size: 13px; - font-family: monospace; } -.outgoing-duration { - color: #666; +.trace-label { + color: #6b7280; + font-size: 12px; + font-weight: 700; + text-transform: uppercase; +} + +.trace-card-meta { + flex-shrink: 0; + gap: 8px; + color: #4b5563; + font-size: 12px; + font-weight: 700; +} + +.trace-details { + display: grid; + gap: 6px; + padding: 0 10px 10px; +} + +.trace-connector { + justify-content: flex-start; + gap: 6px; + padding: 0 12px; + color: #6b7280; + font-size: 11px; + font-weight: 800; + text-transform: uppercase; +} + +.trace-connector span { + display: none; +} + +.trace-connector::before { + color: #9ca3af; + content: "↓"; font-size: 13px; } +.trace-empty { + padding: 12px; + background: #fff; + border: 1px dashed #d1d5db; + border-radius: 8px; +} + +.compare-card { + margin-top: 22px; +} + +.mono-value { + overflow: hidden; + max-width: min(520px, 58vw); + text-overflow: ellipsis; + white-space: nowrap; + font-family: ui-monospace, SFMono-Regular, Consolas, "Liberation Mono", monospace; +} + /* ========================= Table @@ -316,89 +440,136 @@ tbody tr:last-child td { text-align: center; } -/* ========================= - Section Titles -========================= */ - -.payload-group { - margin-top: 24px; - padding: 18px; - background: #fff; - border: 1px solid #e8e8e8; - border-left-width: 5px; - border-radius: 8px; +.compare-controls { + display: grid; + grid-template-columns: minmax(180px, 1fr) minmax(180px, 1fr) auto; + gap: 8px; + padding: 0 10px 10px; } -.outgoing-request-group { - border-left-color: #9b51e0; +.compare-controls input { + min-width: 0; + min-height: 36px; + padding: 0 10px; + background: #fbfbfc; + border: 1px solid #eceff3; + border-radius: 6px; + color: #111827; + font: inherit; } -.request-group { - border-left-color: #2f80ed; +.compare-controls input:focus { + outline: 2px solid rgba(108, 92, 231, 0.18); + border-color: #6c5ce7; } -.response-group { - border-left-color: #27ae60; +.compare-controls button { + min-height: 36px; + padding: 0 14px; + background: #1f2937; + border: 1px solid #111827; + border-radius: 6px; + color: #fff; + cursor: pointer; + font-weight: 700; } -.response-group.response-error { - border-left-color: #e74c3c; +.compare-controls button:hover { + background: #111827; } -.compare-group { - border-left-color: #f39c12; +#compareResult:not(:empty) { + margin: 0 10px 10px; + overflow: hidden; + border: 1px solid #eceff3; + border-radius: 6px; } -.compare-description { - margin-top: 4px; - color: #777; - font-size: 0.9rem; +.payload-panel { + overflow: hidden; + background: #fbfbfc; + border: 1px solid #eceff3; + border-radius: 6px; } -.payload-group-title { - margin-bottom: 16px; - padding-bottom: 12px; - border-bottom: 1px solid #f0f0f0; +.payload-panel summary { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + padding: 8px 10px; + color: #374151; + cursor: pointer; + font-size: 13px; + font-weight: 700; + list-style: none; } -.payload-group-title h3 { - margin: 0; +.payload-panel summary::-webkit-details-marker { + display: none; } -.section-title { - display: flex; - align-items: center; - gap: 10px; - margin-top: 18px; - margin-bottom: 8px; +.payload-panel summary::before { + width: 14px; + color: #6b7280; + content: ">"; + font-family: ui-monospace, SFMono-Regular, Consolas, "Liberation Mono", monospace; + transition: transform 0.12s ease; } -.payload-group-title + .section-title { - margin-top: 0; +.payload-panel[open] summary::before { + transform: rotate(90deg); } -.section-title h3, -.section-title h4 { - margin: 0; +.payload-panel summary span { + margin-right: auto; } -.section-title h4 { - color: #555; - font-size: 13px; +.payload-panel summary small { + color: #6b7280; + font-size: 11px; font-weight: 700; - letter-spacing: 0; + text-transform: uppercase; } -.compare-controls { - display: flex; - flex-wrap: wrap; - gap: 8px; - padding-bottom: 14px; +.payload-panel-empty summary { + cursor: default; } -.compare-controls input { - min-width: 220px; - flex: 1; +.headers-grid { + display: grid; + max-height: 260px; + overflow: auto; + background: #fff; + border-top: 1px solid #eceff3; +} + +.header-row { + display: grid; + grid-template-columns: minmax(150px, 240px) minmax(0, 1fr); + gap: 12px; + padding: 7px 10px; + border-bottom: 1px solid #f2f4f7; + font-size: 12px; +} + +.header-row:last-child { + border-bottom: none; +} + +.header-row span { + overflow: hidden; + color: #4b5563; + text-overflow: ellipsis; + white-space: nowrap; + font-weight: 700; +} + +.header-row code { + overflow-wrap: anywhere; + color: #111827; + font-family: ui-monospace, SFMono-Regular, Consolas, "Liberation Mono", monospace; + font-size: 12px; } @media (max-width: 640px) { @@ -419,14 +590,36 @@ tbody tr:last-child td { grid-template-columns: repeat(2, minmax(0, 1fr)); } - .payload-group { - padding: 14px; + .compare-controls { + grid-template-columns: 1fr; + } + + .details-card-title-split { + align-items: flex-start; + flex-direction: column; } - .compare-controls input, - .compare-controls button { + .details-card-heading { width: 100%; } + + .mono-value { + max-width: 100%; + } + + .trace-card-header { + align-items: flex-start; + flex-direction: column; + } + + .trace-card-meta { + flex-wrap: wrap; + } + + .header-row { + grid-template-columns: 1fr; + gap: 4px; + } } /* ========================= @@ -435,22 +628,25 @@ tbody tr:last-child td { .code-block { position: relative; + border-top: 1px solid #2d3748; } .code-block pre { min-height: 18px; + max-height: 360px; margin: 0; } pre { overflow: auto; margin: 0; - padding: 12px; + padding: 12px 48px 12px 12px; background: #1e1e1e; - border-radius: 6px; + border-radius: 0 0 6px 6px; color: #dcdcdc; font-size: 13px; line-height: 1.4; + tab-size: 2; } /* ========================= diff --git a/DebugProbe.AspNetCore/Assets/html/details.html b/DebugProbe.AspNetCore/Assets/html/details.html index 88ac6a6..e1a298b 100644 --- a/DebugProbe.AspNetCore/Assets/html/details.html +++ b/DebugProbe.AspNetCore/Assets/html/details.html @@ -2,15 +2,14 @@ ← Back -

{{method}} {{path}}

-
-
Transaction Overview
- -
- Status +
+
+ {{method}} + {{path}} +
{{status}} @@ -40,21 +39,19 @@

{{method}} {{path}}

- Request Size - {{requestSize}} B + Completed + {{completed}}
- Response Size - {{responseSize}} B + Dependency Calls + {{dependencyCount}}
-
Environment Overview
- -
- Environment +
+ Environment Overview {{env}}
@@ -90,85 +87,36 @@

{{method}} {{path}}

-
-
-

Outgoing Requests

-
- - {{outgoingRequests}} -
+
+ {{incomingRequest}} - -
-
-

Request

-
- -
-

URL

-
-
- -
{{requestUrl}}
+
+ Outgoing Requests
-
-

Headers

-
- -
- -
{{requestHeaders}}
-
+ {{outgoingRequests}} -
-

Body

-
-
- -
{{request}}
-
+ {{incomingResponse}}
-
-
-

Response

-
- -
-

Headers

-
- -
- -
{{responseHeaders}}
-
- -
-

Body

-
-
- -
{{response}}
-
-
+
+
+
+
+ + Compare Trace + Remote environment +
+
-
-
-

Compare

-

- Compare this trace with another environment/server. -

-
+
+ + + +
-
- - - +
- -
-
diff --git a/DebugProbe.AspNetCore/Internal/Rendering/HtmlRenderer.cs b/DebugProbe.AspNetCore/Internal/Rendering/HtmlRenderer.cs index c77ddc2..de6c448 100644 --- a/DebugProbe.AspNetCore/Internal/Rendering/HtmlRenderer.cs +++ b/DebugProbe.AspNetCore/Internal/Rendering/HtmlRenderer.cs @@ -71,51 +71,49 @@ public static string RenderIndexPage(List items) public static string RenderDetailsPage(DebugEntry x, DebugEnvironment e, string req, string res) { - var requestHeaders = string.Join(Environment.NewLine, x.RequestHeaders.Select(h => $"{h.Key}: {h.Value}")); - var responseHeaders = string.Join(Environment.NewLine, x.ResponseHeaders.Select(h => $"{h.Key}: {h.Value}")); - var pathWithQuery = string.IsNullOrEmpty(x.Query) ? x.Path : $"{x.Path}{x.Query}"; var statusClass = GetStatusClass(x.StatusCode); - var outgoingRequests = string.Join("", x.OutgoingRequests.Select(r => $@" -
-
-
- - {Encode(r.Method)} - - - {Encode(r.Url)} - -
-
- - {(r.StatusCode.HasValue ? GetStatusText(r.StatusCode.Value) : "Failed")} - - - {r.DurationMs} ms - -
-
-
- ")); + var incomingRequest = BuildTraceCard( + "Incoming Request", + x.Method, + string.IsNullOrWhiteSpace(x.RequestUrl) ? pathWithQuery : x.RequestUrl, + "request", + statusCode: x.StatusCode, + durationMs: x.DurationMs, + details: + [ + BuildPayloadSection("URL", string.IsNullOrWhiteSpace(x.RequestUrl) ? pathWithQuery : x.RequestUrl, "url"), + BuildHeaderSection("Headers", x.RequestHeaders), + BuildPayloadSection("Body", req, "body") + ]); + + var incomingResponse = BuildTraceCard( + "Final Response", + "", + "", + x.StatusCode >= 400 ? "response error" : "response", + [ + BuildHeaderSection("Headers", x.ResponseHeaders), + BuildPayloadSection("Body", res, "body") + ]); + + var outgoingRequests = string.Join("", x.OutgoingRequests.Select(BuildOutgoingRequestCard)); var content = EmbeddedResources.Details .Replace("{{method}}", Encode(x.Method)) .Replace("{{path}}", Encode(pathWithQuery)) .Replace("{{status}}", GetStatusText(x.StatusCode)) .Replace("{{statusClass}}", statusClass) - .Replace("{{responseGroupClass}}", GetResponseGroupClass(x.StatusCode)) - .Replace("{{responseStatusCode}}", x.StatusCode.ToString()) .Replace("{{traceId}}", x.Id.ToString()) .Replace("{{time}}", x.Timestamp.ToString("yyyy-MM-dd HH:mm:ss.fff")) .Replace("{{local}}", x.Timestamp.ToLocalTime().ToString("HH:mm:ss")) .Replace("{{durationMs}}", x.DurationMs.ToString()) - .Replace("{{requestSize}}", x.RequestSize.ToString()) - .Replace("{{responseSize}}", x.ResponseSize.ToString()) + .Replace("{{completed}}", x.Timestamp.AddMilliseconds(x.DurationMs).ToLocalTime().ToString("HH:mm:ss")) + .Replace("{{dependencyCount}}", x.OutgoingRequests.Count.ToString()) .Replace("{{env}}", Encode(e.Environment)) .Replace("{{culture}}", Encode(e.Culture)) @@ -125,22 +123,159 @@ public static string RenderDetailsPage(DebugEntry x, DebugEnvironment e, string .Replace("{{decimalSeparator}}", Encode(e.DecimalSeparator)) .Replace("{{dateFormat}}", e.DateFormat ?? "") .Replace("{{assemblyVersion}}", Encode(e.AssemblyVersion)) - .Replace("{{outgoingRequests}}", string.IsNullOrWhiteSpace(outgoingRequests) - ? "
No outgoing requests
" + ? "
No outgoing dependency calls
" : outgoingRequests) + .Replace("{{incomingRequest}}", incomingRequest) + .Replace("{{incomingResponse}}", incomingResponse); - .Replace("{{requestUrl}}", Encode(string.IsNullOrEmpty(x.RequestUrl) ? "" : x.RequestUrl)) - .Replace("{{requestHeaders}}", Encode(requestHeaders)) - .Replace("{{request}}", Encode(string.IsNullOrEmpty(req) ? "" : req)) + return BuildLayout(content); + } - .Replace("{{responseHeaders}}", Encode(responseHeaders)) - .Replace("{{response}}", Encode(string.IsNullOrEmpty(res) ? "" : res) + private static string BuildOutgoingRequestCard(DebugOutgoingRequest request) + { + var classes = request.StatusCode >= 400 || !string.IsNullOrWhiteSpace(request.Exception) + ? "dependency error" + : "dependency"; - ); + var details = new List + { + BuildHeaderSection("Request Headers", request.RequestHeaders), + BuildPayloadSection("Request Body", request.RequestBody, "body"), + BuildHeaderSection("Response Headers", request.ResponseHeaders), + BuildPayloadSection("Response Body", request.ResponseBody, "body") + }; - return BuildLayout(content); + if (!string.IsNullOrWhiteSpace(request.Exception)) + { + details.Add(BuildPayloadSection("Exception", request.Exception, "exception", open: true)); + } + + return BuildTraceCard( + "HttpClient", + request.Method, + request.Url, + classes, + statusCode: request.StatusCode, + statusText: request.StatusCode.HasValue ? null : "Failed", + durationMs: request.DurationMs, + details: details); + } + + private static string BuildTraceCard( + string label, + string method, + string target, + string classes, + IEnumerable details, + int? statusCode = null, + string? statusText = null, + long? durationMs = null) + { + var targetHost = GetDisplayTarget(target); + var status = statusCode.HasValue + ? $@"{Encode(GetStatusText(statusCode.Value))}" + : !string.IsNullOrWhiteSpace(statusText) + ? $@"{Encode(statusText)}" + : ""; + var duration = durationMs.HasValue + ? $@"{durationMs.Value} ms" + : ""; + + var methodPill = !string.IsNullOrWhiteSpace(method) + ? $@"{Encode(method)}" + : ""; + + return $@" +
+
+
+
+ + {Encode(label)} + {methodPill} + {Encode(targetHost)} +
+
+ {status} + {duration} +
+
+
+ {string.Join("", details)} +
+
+
"; + } + + private static string BuildHeaderSection(string title, IReadOnlyDictionary headers) + { + if (headers.Count == 0) + { + return BuildEmptySection(title, "No headers captured"); + } + + var rows = string.Join("", headers.Select(header => $@" +
+ {Encode(header.Key)} + {Encode(header.Value)} +
")); + + return $@" +
+ + {Encode(title)} + {headers.Count} headers + +
+ {rows} +
+
"; + } + + private static string BuildPayloadSection(string title, string? value, string kind, bool open = false) + { + var text = string.IsNullOrWhiteSpace(value) ? "" : JsonUtils.Format(value); + if (string.IsNullOrWhiteSpace(text)) + { + return BuildEmptySection(title, "Empty"); + } + + return $@" +
+ + {Encode(title)} + {Encode(kind)} - {FormatBytes(text.Length)} + +
+ +
{Encode(text)}
+
+
"; + } + + private static string BuildEmptySection(string title, string message) + { + return $@" +
+ + {Encode(title)} + {Encode(message)} + +
"; + } + + private static string GetDisplayTarget(string value) + { + if (Uri.TryCreate(value, UriKind.Absolute, out var uri)) + { + return string.IsNullOrWhiteSpace(uri.PathAndQuery) + ? uri.Host + : $"{uri.Host}{uri.PathAndQuery}"; + } + + return value; } private static string Encode(string? value) @@ -165,11 +300,6 @@ private static string GetStatusClass(int statusCode) }; } - private static string GetResponseGroupClass(int statusCode) - { - return statusCode >= 400 ? "response-error" : ""; - } - private static string FormatCompactNumber(int value) { return value switch @@ -180,4 +310,14 @@ private static string FormatCompactNumber(int value) }; } + private static string FormatBytes(int value) + { + return value switch + { + >= 1_048_576 => $"{value / 1_048_576d:0.#} MB", + >= 1024 => $"{value / 1024d:0.#} KB", + _ => $"{value} B" + }; + } + } diff --git a/DebugProbe.SampleApi/Controllers/DemoController.cs b/DebugProbe.SampleApi/Controllers/DemoController.cs index ffd7b7b..de4967f 100644 --- a/DebugProbe.SampleApi/Controllers/DemoController.cs +++ b/DebugProbe.SampleApi/Controllers/DemoController.cs @@ -16,6 +16,34 @@ public DemoController(ILogger logger, IHttpClientFactory httpCli _httpClientFactory = httpClientFactory; } + [HttpPost("ExecuteExternalRequests")] + public async Task ExecuteExternalRequests() + { + var client = _httpClientFactory.CreateClient(); + + var createPostResponse = await client.PostAsJsonAsync( + "https://jsonplaceholder.typicode.com/posts", + new + { + title = "DebugProbe", + body = "Tracing test", + userId = 1 + }); + + var createPostContent = await createPostResponse.Content.ReadAsStringAsync(); + + var usersResponse = await client.GetAsync( + "https://jsonplaceholder.typicode.com/users"); + + var usersContent = await usersResponse.Content.ReadAsStringAsync(); + + return Ok(new + { + Post = createPostContent, + Users = usersContent + }); + } + [HttpPost("CallExternalPost")] public async Task CallExternalPost() { From 040c0d409fe9ff11f10c7cf4631d52a3c40d2db6 Mon Sep 17 00:00:00 2001 From: Georgi Hristov Date: Sat, 23 May 2026 23:21:08 +0300 Subject: [PATCH 4/7] refactor(ui): align compare section with trace flow design --- .../Assets/css/debugprobe.css | 158 +++++++++++------- .../Assets/js/debugprobe-compare-renderer.js | 130 +++++++------- 2 files changed, 175 insertions(+), 113 deletions(-) diff --git a/DebugProbe.AspNetCore/Assets/css/debugprobe.css b/DebugProbe.AspNetCore/Assets/css/debugprobe.css index 43ba66b..0a62da9 100644 --- a/DebugProbe.AspNetCore/Assets/css/debugprobe.css +++ b/DebugProbe.AspNetCore/Assets/css/debugprobe.css @@ -44,8 +44,6 @@ h4 { .toolbar, .index-header, .topbar, -.accordion-header, -.accordion-meta, .details-item, .json-compare { display: flex; @@ -55,7 +53,6 @@ h4 { .toolbar, .index-header, .topbar, -.accordion-header, .details-item { justify-content: space-between; } @@ -594,6 +591,15 @@ tbody tr:last-child td { grid-template-columns: 1fr; } + .json-compare, + .compare-row { + grid-template-columns: 1fr; + } + + .compare-row-head { + display: none; + } + .details-card-title-split { align-items: flex-start; flex-direction: column; @@ -654,33 +660,108 @@ pre { ========================= */ .json-compare { - align-items: flex-start; - gap: 20px; + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 10px; } - .json-compare > div { - min-width: 0; - flex: 1; - padding: 10px; - border-bottom: 1px solid #eee; - text-align: left; - } +.compare-section { + margin-top: 6px; +} -.json-error { - margin-bottom: 4px; - padding: 4px 8px; - background: rgba(231, 76, 60, 0.12); - border: 1px solid rgba(231, 76, 60, 0.25); +.compare-section-body { + display: grid; + gap: 10px; + padding: 10px; + background: #fff; + border-top: 1px solid #eceff3; +} + +.compare-table { + overflow: hidden; + border: 1px solid #eceff3; border-radius: 6px; - color: #ff8a8a; +} + +.compare-row { + display: grid; + grid-template-columns: minmax(120px, 0.8fr) minmax(0, 1fr) minmax(0, 1fr); + gap: 12px; + padding: 8px 10px; + border-bottom: 1px solid #f2f4f7; font-size: 12px; } +.compare-row:last-child { + border-bottom: none; +} + +.compare-row-head { + background: #fbfbfc; + color: #6b7280; + font-weight: 800; + text-transform: uppercase; +} + +.compare-row span { + color: #4b5563; + font-weight: 700; +} + +.compare-row code { + overflow-wrap: anywhere; + color: #111827; + font-family: ui-monospace, SFMono-Regular, Consolas, "Liberation Mono", monospace; + font-size: 12px; +} + +.compare-row-changed { + background: rgba(255, 200, 0, 0.12); +} + +.compare-row-changed code { + color: #b45309; +} + +.compare-pane { + min-width: 0; +} + .compare-pane-title { display: flex; align-items: center; - gap: 10px; - margin-bottom: 6px; + justify-content: space-between; + min-height: 32px; + padding: 0 10px; + background: #fbfbfc; + border: 1px solid #eceff3; + border-bottom: none; + border-radius: 6px 6px 0 0; + color: #374151; + font-size: 13px; + font-weight: 800; +} + +.compare-empty, +.compare-message { + padding: 10px; + background: #fbfbfc; + border: 1px dashed #d1d5db; + border-radius: 6px; + color: #6b7280; + font-size: 13px; + font-weight: 700; +} + +.compare-message { + margin: 0 10px 10px; + border-style: solid; +} + +.compare-message-error { + background: rgba(231, 76, 60, 0.08); + border-color: rgba(231, 76, 60, 0.2); + color: #b42318; } /* ========================= @@ -848,40 +929,3 @@ pre { color: #ff8a8a !important; } -/* ========================= - Accordion -========================= */ - -.accordion-section { - overflow: hidden; - margin-bottom: 14px; - background: #fff; - border: 1px solid #e9e9e9; - border-radius: 8px; -} - -.accordion-header { - padding: 18px; - cursor: pointer; -} - - .accordion-header:hover { - background: #f3f3f3; - } - -.accordion-title { - font-size: 16px; - font-weight: 600; -} - -.accordion-meta { - gap: 10px; -} - -.accordion-body { - display: none; -} - - .accordion-body.open { - display: block; - } diff --git a/DebugProbe.AspNetCore/Assets/js/debugprobe-compare-renderer.js b/DebugProbe.AspNetCore/Assets/js/debugprobe-compare-renderer.js index a76edf7..7c00861 100644 --- a/DebugProbe.AspNetCore/Assets/js/debugprobe-compare-renderer.js +++ b/DebugProbe.AspNetCore/Assets/js/debugprobe-compare-renderer.js @@ -11,7 +11,7 @@ return; } - setCompareResult('Comparing...'); + setCompareResult('
Comparing...
'); try { @@ -24,7 +24,7 @@ const text = await res.text(); - setCompareResult(`${escapeHtml(text || 'Compare failed')}`); + setCompareResult(`
${escapeHtml(text || 'Compare failed')}
`); return; } @@ -36,7 +36,7 @@ } catch (error) { setCompareResult( - `${escapeHtml(error.message || 'Compare failed')}` + `
${escapeHtml(error.message || 'Compare failed')}
` ); } }; @@ -90,15 +90,15 @@ function renderCompare(result) { const overviewRowsChangedCount = getChangedCount(overviewRows); - const localRequestBodyJson = result.requestBody?.local || ''; + const localRequestBodyJson = normalizeJsonPayload(result.requestBody?.local || ''); - const remoteRequestBodyJson = result.requestBody?.remote || ''; + const remoteRequestBodyJson = normalizeJsonPayload(result.requestBody?.remote || ''); const requestComparison = compareJsonBodies(localRequestBodyJson, remoteRequestBodyJson); - const localResponseBodyJson = result.responseBody?.local || ''; + const localResponseBodyJson = normalizeJsonPayload(result.responseBody?.local || ''); - const remoteResponseBodyJson = result.responseBody?.remote || ''; + const remoteResponseBodyJson = normalizeJsonPayload(result.responseBody?.remote || ''); const responseComparison = compareJsonBodies(localResponseBodyJson, remoteResponseBodyJson); @@ -149,29 +149,24 @@ function renderSectionRows(rows) { rows.map(row => { const changed = row.local !== row.remote; - const rowStyle = changed ? ' style="background:rgba(255,200,0,0.12)"' : ''; - - const valueStyle = changed ? ' style="color:#e74c3c"' : ''; - return ` - - ${escapeHtml(row.field)} - ${escapeHtml(row.local ?? '')} - ${escapeHtml(row.remote ?? '')} - +
+ ${escapeHtml(row.field)} + ${escapeHtml(row.local ?? '')} + ${escapeHtml(row.remote ?? '')} +
`; }).join(''); return ` - - - - - - - +
+
+ Field + Local + Remote +
${body} -
FieldLocalRemote
+
`; } @@ -179,17 +174,17 @@ function renderSideBySideJson(comparison, localJson, remoteJson ) { return `
-
+
- Local + Local
${renderAlignedJson(comparison.local,localJson)}
-
+
- Remote + Remote
${renderAlignedJson(comparison.remote, remoteJson )} @@ -201,31 +196,22 @@ function renderSideBySideJson(comparison, localJson, remoteJson ) { function renderAccordionSection(title, content, expanded = false, changes = 0) { return ` -
- -
- -
- ${escapeHtml(title)} -
- -
- - ${expanded ? '-' : '+'} - - -
-
- -
+
+ + ${escapeHtml(title)} + ${changes > 0 ? `${changes} changes` : 'No changes'} + +
${content}
- -
+ `; } function renderAlignedJson(lines, originalJson) { + if (!originalJson || originalJson.trim() === '') { + return '
Empty body
'; + } const content = lines.map(line => { @@ -249,27 +235,59 @@ function renderAlignedJson(lines, originalJson) { `; } -function formatCopyValue(json, lines) { +function normalizeJsonPayload(value) { + if (!value || !value.trim()) { + return ''; + } try { + return JSON.stringify(expandJsonStrings(JSON.parse(value)), null, 2); + } catch { + return value; + } +} - return JSON.stringify(JSON.parse(json), null, 2); +function expandJsonStrings(value) { + if (typeof value === 'string') { + const trimmed = value.trim(); + + if ( + (trimmed.startsWith('{') && trimmed.endsWith('}')) || + (trimmed.startsWith('[') && trimmed.endsWith(']')) + ) { + try { + return expandJsonStrings(JSON.parse(trimmed)); + } catch { + return value; + } + } - } catch { + return value; + } - return lines.map(line => line.text).join('\n'); + if (Array.isArray(value)) { + return value.map(expandJsonStrings); + } + + if (value && typeof value === 'object') { + return Object.fromEntries( + Object.entries(value).map(([key, child]) => [key, expandJsonStrings(child)]) + ); } + + return value; } -function toggleAccordion(header) { +function formatCopyValue(json, lines) { - const body = header.nextElementSibling; + try { - const toggle = header.querySelector('.accordion-toggle'); + return JSON.stringify(JSON.parse(json), null, 2); - body.classList.toggle('open'); + } catch { - toggle.textContent = body.classList.contains('open') ? '-' : '+'; + return lines.map(line => line.text).join('\n'); + } } function getChangedCount(rows) { From 202e6b0c1a71e5dbeea11476e3c33a7b78f98f4a Mon Sep 17 00:00:00 2001 From: Georgi Hristov Date: Sun, 24 May 2026 00:16:26 +0300 Subject: [PATCH 5/7] feat(json): improve nested JSON formatting support --- .../Assets/css/debugprobe.css | 5 +- .../Internal/Utils/JsonUtils.cs | 73 ++++++++++++++++++- 2 files changed, 73 insertions(+), 5 deletions(-) diff --git a/DebugProbe.AspNetCore/Assets/css/debugprobe.css b/DebugProbe.AspNetCore/Assets/css/debugprobe.css index 0a62da9..1cfa61b 100644 --- a/DebugProbe.AspNetCore/Assets/css/debugprobe.css +++ b/DebugProbe.AspNetCore/Assets/css/debugprobe.css @@ -646,7 +646,7 @@ tbody tr:last-child td { pre { overflow: auto; margin: 0; - padding: 12px 48px 12px 12px; + padding: 12px 84px 12px 12px; background: #1e1e1e; border-radius: 0 0 6px 6px; color: #dcdcdc; @@ -840,9 +840,10 @@ pre { .copy-btn { position: absolute; top: 8px; - right: 8px; + right: 28px; padding: 4px 8px; font-size: 11px; + z-index: 1; } .copy-btn:hover, diff --git a/DebugProbe.AspNetCore/Internal/Utils/JsonUtils.cs b/DebugProbe.AspNetCore/Internal/Utils/JsonUtils.cs index 17e52d7..de1a6d1 100644 --- a/DebugProbe.AspNetCore/Internal/Utils/JsonUtils.cs +++ b/DebugProbe.AspNetCore/Internal/Utils/JsonUtils.cs @@ -1,5 +1,6 @@ using System.Text.Encodings.Web; using System.Text.Json; +using System.Text.Json.Nodes; namespace DebugProbe.AspNetCore.Internal.Utils; @@ -12,10 +13,10 @@ public static string Format(string json) try { - using var document = JsonDocument.Parse(json); + var node = JsonNode.Parse(json); return JsonSerializer.Serialize( - document.RootElement, + ExpandJsonStrings(node), new JsonSerializerOptions { WriteIndented = true, @@ -45,4 +46,70 @@ public static bool IsValidJson(string value) return false; } } -} \ No newline at end of file + + private static JsonNode? ExpandJsonStrings(JsonNode? node) + { + if (node is JsonObject jsonObject) + { + var expandedObject = new JsonObject(); + + foreach (var property in jsonObject) + { + expandedObject[property.Key] = ExpandJsonStrings(property.Value); + } + + return expandedObject; + } + + if (node is JsonArray jsonArray) + { + var expandedArray = new JsonArray(); + + foreach (var item in jsonArray) + { + expandedArray.Add(ExpandJsonStrings(item)); + } + + return expandedArray; + } + + if (node is JsonValue jsonValue && + jsonValue.TryGetValue(out var text) && + TryParseNestedJson(text, out var nested)) + { + return ExpandJsonStrings(nested); + } + + return node?.DeepClone(); + } + + private static bool TryParseNestedJson(string value, out JsonNode? node) + { + node = null; + + if (string.IsNullOrWhiteSpace(value)) + { + return false; + } + + var trimmed = value.Trim(); + var looksLikeJson = + (trimmed.StartsWith('{') && trimmed.EndsWith('}')) || + (trimmed.StartsWith('[') && trimmed.EndsWith(']')); + + if (!looksLikeJson) + { + return false; + } + + try + { + node = JsonNode.Parse(trimmed); + return node is not null; + } + catch + { + return false; + } + } +} From 46280c85396e0cd4c5b21fba05b3a4957cc2068c Mon Sep 17 00:00:00 2001 From: Georgi Hristov Date: Sun, 24 May 2026 00:33:26 +0300 Subject: [PATCH 6/7] refactor(httpclient): improve handler naming and documentation --- .../Assets/html/details.html | 2 +- .../Handlers/DebugProbeHttpClientHandler.cs | 20 ++++++++++-- .../Internal/Rendering/HtmlRenderer.cs | 32 ++++--------------- .../Options/DebugProbeOptions.cs | 2 +- 4 files changed, 26 insertions(+), 30 deletions(-) diff --git a/DebugProbe.AspNetCore/Assets/html/details.html b/DebugProbe.AspNetCore/Assets/html/details.html index e1a298b..83c8a8f 100644 --- a/DebugProbe.AspNetCore/Assets/html/details.html +++ b/DebugProbe.AspNetCore/Assets/html/details.html @@ -16,7 +16,7 @@
- TraceId + Trace ID