diff --git a/DebugProbe.AspNetCore.Tests/Compare/DebugEntryComparerTests.cs b/DebugProbe.AspNetCore.Tests/Compare/DebugEntryComparerTests.cs new file mode 100644 index 0000000..056fc28 --- /dev/null +++ b/DebugProbe.AspNetCore.Tests/Compare/DebugEntryComparerTests.cs @@ -0,0 +1,32 @@ +using System.Text.Json; +using DebugProbe.AspNetCore.Internal.Compare; +using DebugProbe.AspNetCore.Models; + +namespace DebugProbe.AspNetCore.Tests.Compare; + +public class DebugEntryComparerTests +{ + [Fact] + public void Compare_reports_status_and_json_body_differences() + { + var local = new DebugEntry + { + StatusCode = 200, + RequestBody = "{\"id\":1}", + ResponseBody = "{\"total\":10,\"items\":[\"a\"]}" + }; + var remote = new DebugEntry + { + StatusCode = 500, + RequestBody = "{\"id\":2}", + ResponseBody = "{\"total\":12,\"items\":[\"a\",\"b\"]}" + }; + + var json = JsonSerializer.Serialize(DebugEntryComparer.Compare(local, remote)); + + Assert.Contains("\"field\":\"Status\"", json); + Assert.Contains("\"field\":\"id\"", json); + Assert.Contains("\"field\":\"total\"", json); + Assert.Contains("\"field\":\"items\"", json); + } +} diff --git a/DebugProbe.AspNetCore.Tests/Configuration/DebugProbeOptionsTests.cs b/DebugProbe.AspNetCore.Tests/Configuration/DebugProbeOptionsTests.cs index d8f5ccf..ff41b40 100644 --- a/DebugProbe.AspNetCore.Tests/Configuration/DebugProbeOptionsTests.cs +++ b/DebugProbe.AspNetCore.Tests/Configuration/DebugProbeOptionsTests.cs @@ -13,7 +13,7 @@ public void Defaults_work_correctly() var options = new DebugProbeOptions(); Assert.Equal(20, options.MaxEntries); - Assert.Equal(256, options.MaxBodyCaptureSizeKb); + Assert.Equal(32, options.MaxBodyCaptureSizeKb); Assert.False(options.AllowLocalCompareTargets); Assert.Empty(options.IgnorePaths); } diff --git a/DebugProbe.AspNetCore.Tests/Handlers/DebugProbeHttpClientHandlerTests.cs b/DebugProbe.AspNetCore.Tests/Handlers/DebugProbeHttpClientHandlerTests.cs new file mode 100644 index 0000000..78481b6 --- /dev/null +++ b/DebugProbe.AspNetCore.Tests/Handlers/DebugProbeHttpClientHandlerTests.cs @@ -0,0 +1,62 @@ +using System.Net; +using System.Text; +using DebugProbe.AspNetCore.Handlers; +using DebugProbe.AspNetCore.Models; +using DebugProbe.AspNetCore.Options; +using Microsoft.AspNetCore.Http; + +namespace DebugProbe.AspNetCore.Tests.Handlers; + +public class DebugProbeHttpClientHandlerTests +{ + [Fact] + public async Task Captures_outgoing_http_call_on_active_trace() + { + var entry = new DebugEntry(); + var context = new DefaultHttpContext(); + context.Items["DebugProbeEntry"] = entry; + + using var handler = new DebugProbeHttpClientHandler( + new HttpContextAccessor { HttpContext = context }, + new DebugProbeOptions()) + { + InnerHandler = new StubHandler(_ => + { + var response = new HttpResponseMessage(HttpStatusCode.Created) + { + Content = new StringContent("{\"ok\":true}", Encoding.UTF8, "application/json") + }; + response.Headers.Add("X-Trace", "remote"); + return response; + }) + }; + + using var client = new HttpClient(handler); + using var request = new HttpRequestMessage(HttpMethod.Post, "https://api.example.test/orders") + { + Content = new StringContent("{\"name\":\"Ada\"}", Encoding.UTF8, "application/json") + }; + request.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", "secret"); + + using var response = await client.SendAsync(request); + + Assert.Equal(HttpStatusCode.Created, response.StatusCode); + var outgoing = Assert.Single(entry.OutgoingRequests); + Assert.Equal("POST", outgoing.Method); + Assert.Equal("https://api.example.test/orders", outgoing.Url); + Assert.Equal(201, outgoing.StatusCode); + Assert.True(outgoing.IsSuccessStatusCode); + Assert.Equal("[REDACTED]", outgoing.RequestHeaders["Authorization"]); + Assert.Equal("remote", outgoing.ResponseHeaders["X-Trace"]); + Assert.Contains("\"name\": \"Ada\"", outgoing.RequestBody); + Assert.Contains("\"ok\": true", outgoing.ResponseBody); + } + + private sealed class StubHandler(Func send) : HttpMessageHandler + { + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + return Task.FromResult(send(request)); + } + } +} diff --git a/DebugProbe.AspNetCore.Tests/Middleware/ResponseBodyCaptureTests.cs b/DebugProbe.AspNetCore.Tests/Middleware/ResponseBodyCaptureTests.cs index b6a1ab6..08abaf2 100644 --- a/DebugProbe.AspNetCore.Tests/Middleware/ResponseBodyCaptureTests.cs +++ b/DebugProbe.AspNetCore.Tests/Middleware/ResponseBodyCaptureTests.cs @@ -1,4 +1,3 @@ -using System.Net.Http.Headers; using DebugProbe.AspNetCore.Tests.Infrastructure; namespace DebugProbe.AspNetCore.Tests.Middleware; diff --git a/DebugProbe.AspNetCore.Tests/Rendering/HtmlRendererTests.cs b/DebugProbe.AspNetCore.Tests/Rendering/HtmlRendererTests.cs index b2456e4..7d5440d 100644 --- a/DebugProbe.AspNetCore.Tests/Rendering/HtmlRendererTests.cs +++ b/DebugProbe.AspNetCore.Tests/Rendering/HtmlRendererTests.cs @@ -41,15 +41,14 @@ public void Details_page_renders_captured_values() Assert.Contains("/orders?id=10", html); Assert.Contains("500 InternalServerError", html); Assert.Contains("http://example.test/orders?id=10", html); - Assert.Contains(""request":true", html); - Assert.Contains(""response":true", html); + Assert.Contains(""request": true", html); + Assert.Contains(""response": true", html); } [Fact] public void Payload_groups_render_for_json_empty_text_and_hidden_payloads() { var jsonHtml = HtmlRenderer.RenderDetailsPage(CreateEntry(), CreateEnvironment(), "{\"ok\":true}", "plain"); - var emptyHtml = HtmlRenderer.RenderDetailsPage(CreateEntry(), CreateEnvironment(), "", ""); var hiddenHtml = HtmlRenderer.RenderDetailsPage(CreateEntry(), CreateEnvironment(), "[Body too large]", "[Body too large]"); Assert.Contains("Request", jsonHtml); diff --git a/DebugProbe.AspNetCore/Assets/css/debugprobe.css b/DebugProbe.AspNetCore/Assets/css/debugprobe.css index 6fd2089..1cfa61b 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,14 +38,12 @@ h4 { ========================= */ .container { - padding: 20px; + padding: 18px 20px 28px; } .toolbar, .index-header, .topbar, -.accordion-header, -.accordion-meta, .details-item, .json-compare { display: flex; @@ -54,7 +53,6 @@ h4 { .toolbar, .index-header, .topbar, -.accordion-header, .details-item { justify-content: space-between; } @@ -157,13 +155,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,31 +171,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: 13px; + font-weight: 600; +} + +/* ========================= + Trace Details +========================= */ +.trace-card-header, +.trace-card-title, +.trace-card-meta { + display: flex; + align-items: center; +} + +.trace-flow { + position: relative; + display: grid; + gap: 8px; + margin-top: 14px; +} + +.trace-group-label { + margin-left: 30px; + margin-bottom: -8px; + font-size: 12px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.08em; + color: #8b8b95; +} + +.trace-card { + min-width: 0; +} + +.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; + background: #fff; + border: 1px solid #e5e7eb; + border-radius: 8px; + box-shadow: 0 1px 1px rgba(15, 23, 42, 0.03); +} + +.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; +} + +.trace-card-title strong { + overflow: hidden; + color: #111827; + text-overflow: ellipsis; + white-space: nowrap; + font-family: ui-monospace, SFMono-Regular, Consolas, "Liberation Mono", monospace; + font-size: 13px; +} + +.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; +} - .details-item strong { - font-size: 15px; - font-weight: 600; - } /* ========================= Table @@ -246,7 +420,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; @@ -263,85 +437,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; -} - -.request-group { - border-left-color: #2f80ed; +.compare-controls { + display: grid; + grid-template-columns: minmax(180px, 1fr) minmax(180px, 1fr) auto; + gap: 8px; + padding: 0 10px 10px; } -.response-group { - border-left-color: #27ae60; +.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; } -.response-group.response-error { - border-left-color: #e74c3c; +.compare-controls input:focus { + outline: 2px solid rgba(108, 92, 231, 0.18); + border-color: #6c5ce7; } -.compare-group { - border-left-color: #f39c12; +.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; } -.compare-description { - margin-top: 4px; - color: #777; - font-size: 0.9rem; +.compare-controls button:hover { + background: #111827; } -.payload-group-title { - margin-bottom: 16px; - padding-bottom: 12px; - border-bottom: 1px solid #f0f0f0; +#compareResult:not(:empty) { + margin: 0 10px 10px; + overflow: hidden; + border: 1px solid #eceff3; + border-radius: 6px; } -.payload-group-title h3 { - margin: 0; +.payload-panel { + overflow: hidden; + background: #fbfbfc; + border: 1px solid #eceff3; + border-radius: 6px; } -.section-title { +.payload-panel summary { display: flex; align-items: center; + justify-content: space-between; gap: 10px; - margin-top: 18px; - margin-bottom: 8px; + padding: 8px 10px; + color: #374151; + cursor: pointer; + font-size: 13px; + font-weight: 700; + list-style: none; } -.payload-group-title + .section-title { - margin-top: 0; +.payload-panel summary::-webkit-details-marker { + display: none; } -.section-title h3, -.section-title h4 { - margin: 0; +.payload-panel summary::before { + width: 14px; + color: #6b7280; + content: ">"; + font-family: ui-monospace, SFMono-Regular, Consolas, "Liberation Mono", monospace; + transition: transform 0.12s ease; } -.section-title h4 { - color: #555; - font-size: 13px; +.payload-panel[open] summary::before { + transform: rotate(90deg); +} + +.payload-panel summary span { + margin-right: auto; +} + +.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) { @@ -362,14 +587,45 @@ tbody tr:last-child td { grid-template-columns: repeat(2, minmax(0, 1fr)); } - .payload-group { - padding: 14px; + .compare-controls { + grid-template-columns: 1fr; + } + + .json-compare, + .compare-row { + grid-template-columns: 1fr; + } + + .compare-row-head { + display: none; } - .compare-controls input, - .compare-controls button { + .details-card-title-split { + align-items: flex-start; + flex-direction: column; + } + + .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; + } } /* ========================= @@ -378,22 +634,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 84px 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; } /* ========================= @@ -401,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; } /* ========================= @@ -506,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, @@ -595,40 +930,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/html/details.html b/DebugProbe.AspNetCore/Assets/html/details.html index 5a49097..83c8a8f 100644 --- a/DebugProbe.AspNetCore/Assets/html/details.html +++ b/DebugProbe.AspNetCore/Assets/html/details.html @@ -2,22 +2,21 @@ ← Back -

{{method}} {{path}}

-
-
Transaction Overview
- -
- Status +
+
+ {{method}} + {{path}} +
{{status}}
- TraceId + Trace ID
- Request Size - {{requestSize}} B + Completed + {{completed}}
- Response Size - {{responseSize}} B + Dependency Calls + {{dependencyCount}}
-
Environment Overview
- -
- Environment +
+ Environment Overview {{env}}
@@ -90,77 +87,36 @@

{{method}} {{path}}

+
+ {{incomingRequest}} -
-
-

Request

-
- -
-

URL

-
-
- -
{{requestUrl}}
-
- -
-

Headers

+
+ Outgoing Requests
-
- -
{{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/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) { 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..2791d99 --- /dev/null +++ b/DebugProbe.AspNetCore/Handlers/DebugProbeHttpClientHandler.cs @@ -0,0 +1,124 @@ +using System.Diagnostics; +using DebugProbe.AspNetCore.Internal.Utils; +using DebugProbe.AspNetCore.Models; +using DebugProbe.AspNetCore.Options; +using Microsoft.AspNetCore.Http; + +namespace DebugProbe.AspNetCore.Handlers; + +/// +/// Captures outgoing HttpClient requests and responses. +/// +public class DebugProbeHttpClientHandler : DelegatingHandler +{ + private static readonly HashSet SensitiveHeaders = + [ + "Authorization", + "Cookie", + "Set-Cookie" + ]; + + private readonly DebugProbeOptions _options; + + private readonly IHttpContextAccessor _httpContextAccessor; + + public DebugProbeHttpClientHandler(IHttpContextAccessor httpContextAccessor, DebugProbeOptions options) + { + _httpContextAccessor = httpContextAccessor; + + _options = options; + } + + /// + /// Sends the HTTP request and captures tracing information. + /// + protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + var started = Stopwatch.StartNew(); + + try + { + var response = await base.SendAsync(request, cancellationToken); + + await CaptureRequest(request, response, null, started.ElapsedMilliseconds); + + return response; + } + catch (Exception ex) + { + await CaptureRequest(request, null, ex, started.ElapsedMilliseconds); + + throw; + } + } + + /// + /// Captures outgoing request details and stores them in the active DebugProbe entry. + /// + private async Task 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; + } + + var outgoing = 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)) : [] + }; + + 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, _options.MaxBodyCaptureSizeKb * 1024)); + } + } + + 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, _options.MaxBodyCaptureSizeKb * 1024)); + } + } + + entry.OutgoingRequests.Add(outgoing); + } +} \ No newline at end of file diff --git a/DebugProbe.AspNetCore/Internal/Rendering/HtmlRenderer.cs b/DebugProbe.AspNetCore/Internal/Rendering/HtmlRenderer.cs index 03c6525..730674e 100644 --- a/DebugProbe.AspNetCore/Internal/Rendering/HtmlRenderer.cs +++ b/DebugProbe.AspNetCore/Internal/Rendering/HtmlRenderer.cs @@ -51,13 +51,9 @@ public static string RenderIndexPage(List items) .Select(method => $@"")); var totalRequests = items.Count; - var averageResponseMs = totalRequests == 0 - ? 0 - : (int)Math.Round(items.Average(x => x.DurationMs)); + var averageResponseMs = totalRequests == 0 ? 0 : (int)Math.Round(items.Average(x => x.DurationMs)); var slowRequests = items.Count(x => x.DurationMs >= slowRequestThresholdMs); - var errorRate = totalRequests == 0 - ? 0 - : items.Count(x => x.StatusCode >= 400) * 100d / totalRequests; + var errorRate = totalRequests == 0 ? 0 : items.Count(x => x.StatusCode >= 400) * 100d / totalRequests; return BuildLayout(EmbeddedResources.Index .Replace("{{rows}}", rows) @@ -71,33 +67,49 @@ 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 pathWithQuery = string.IsNullOrEmpty(x.Query) ? x.Path : $"{x.Path}{x.Query}"; - 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 statusClass = GetStatusClass(x.StatusCode); - var pathWithQuery = string.IsNullOrEmpty(x.Query) - ? x.Path - : $"{x.Path}{x.Query}"; + 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 statusClass = GetStatusClass(x.StatusCode); + 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)) @@ -107,17 +119,145 @@ 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 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"; - //.Replace("{{headers}}", headers); + 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( + "Http Client", + 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) @@ -142,11 +282,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 @@ -157,4 +292,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.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/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; + } + } +} diff --git a/DebugProbe.AspNetCore/Middleware/DebugProbeMiddleware.cs b/DebugProbe.AspNetCore/Middleware/DebugProbeMiddleware.cs index 04a7a6f..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; @@ -16,6 +17,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 +79,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 +111,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, - }); + var responseBody = exception ? HttpContentUtils.Trim(exceptionResponseBody, maxBodySize) : CaptureResponseBody(context, responseCapture, maxBodySize); + + 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 = HttpContentUtils.Trim(requestBody, maxBodySize); + + entry.ResponseBody = HttpContentUtils.Trim(responseBody, maxBodySize); + + entry.ResponseHeaders = + context.Response.Headers.ToDictionary( + x => x.Key, + x => SensitiveHeaders.Contains(x.Key) ? "[REDACTED]" : x.Value.ToString()); + + store.Add(entry); } } @@ -151,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; } @@ -169,17 +183,17 @@ 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) { - if (!IsTextContent(context.Response.ContentType)) + if (!HttpContentUtils.IsTextContent(context.Response.ContentType)) { return responseCapture.TotalBytesWritten == 0 ? string.Empty @@ -211,30 +225,20 @@ 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) { 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,15 +246,10 @@ private static async Task ReadAtMostAsync(Stream stream, int byteLimit) } await buffer.WriteAsync(chunk.AsMemory(0, read)); + remaining -= read; } 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.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/Options/DebugProbeOptions.cs b/DebugProbe.AspNetCore/Options/DebugProbeOptions.cs index fe6ee3d..ed7c46f 100644 --- a/DebugProbe.AspNetCore/Options/DebugProbeOptions.cs +++ b/DebugProbe.AspNetCore/Options/DebugProbeOptions.cs @@ -13,7 +13,7 @@ public class DebugProbeOptions /// /// Maximum captured request or response body size in kilobytes. /// - public int MaxBodyCaptureSizeKb { get; set; } = 256; + public int MaxBodyCaptureSizeKb { get; set; } = 32; /// /// Allows compare requests to local or private network targets. 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..de4967f 100644 --- a/DebugProbe.SampleApi/Controllers/DemoController.cs +++ b/DebugProbe.SampleApi/Controllers/DemoController.cs @@ -7,11 +7,77 @@ 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; + } + + [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() + { + 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(); + + 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}")]