Skip to content

Commit 891f4cc

Browse files
feat: csharp snippet and exception grouping
Snippet and exception grouping
2 parents 946527f + 178e7b8 commit 891f4cc

7 files changed

Lines changed: 395 additions & 21 deletions

File tree

DebugProbe.AspNetCore.Tests/Rendering/HtmlRendererTests.cs

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -204,6 +204,44 @@ public void Details_page_renders_curl_copy_attributes_and_buttons()
204204
Assert.Equal(2, occurrences);
205205
}
206206

207+
[Fact]
208+
public void Details_page_renders_csharp_copy_attributes_and_buttons()
209+
{
210+
var entry = CreateEntry();
211+
entry.OutgoingRequests.Add(new DebugOutgoingRequest
212+
{
213+
Method = "PUT",
214+
Url = "https://external-api.test/v1/update",
215+
StatusCode = 200,
216+
DurationMs = 120,
217+
RequestBody = "{\"name\":\"John\"}",
218+
RequestHeaders = new Dictionary<string, string> { ["Authorization"] = "Bearer token" }
219+
});
220+
221+
var html = HtmlRenderer.RenderDetailsPage(
222+
entry,
223+
CreateEnvironment(),
224+
"{\"request\":true}",
225+
"{\"response\":true}");
226+
227+
// 1. Verify Incoming Request Card attributes and button
228+
Assert.Contains("data-method=\"POST\"", html);
229+
Assert.Contains("data-url=\"http://example.test/orders?id=10\"", html);
230+
Assert.Contains("data-headers=\"{&quot;X-Test&quot;:&quot;yes&quot;}\"", html);
231+
Assert.Contains("data-body=\"{&quot;request&quot;:true}\"", html);
232+
Assert.Contains("class=\"csharp-copy-btn\"", html);
233+
234+
// 2. Verify Outgoing Request Card attributes and button
235+
Assert.Contains("data-method=\"PUT\"", html);
236+
Assert.Contains("data-url=\"https://external-api.test/v1/update\"", html);
237+
Assert.Contains("data-headers=\"{&quot;Authorization&quot;:&quot;Bearer token&quot;}\"", html);
238+
Assert.Contains("data-body=\"{&quot;name&quot;:&quot;John&quot;}\"", html);
239+
240+
// 3. Verify Response Card has no C# copy button (only 2 copy C# buttons in total should exist in HTML markup)
241+
var occurrences = (html.Length - html.Replace("class=\"csharp-copy-btn\"", "").Length) / "class=\"csharp-copy-btn\"".Length;
242+
Assert.Equal(2, occurrences);
243+
}
244+
207245
private static DebugEntry CreateEntry()
208246
{
209247
return new DebugEntry
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
using System.Linq;
2+
using DebugProbe.AspNetCore.Models;
3+
using DebugProbe.AspNetCore.Options;
4+
using DebugProbe.AspNetCore.Storage;
5+
using Xunit;
6+
7+
namespace DebugProbe.AspNetCore.Tests.Storage;
8+
9+
public class DebugEntryStoreTests
10+
{
11+
[Fact]
12+
public void Add_identical_type_and_message_increments_count()
13+
{
14+
var store = new DebugEntryStore(new DebugProbeOptions());
15+
var entry1 = new DebugEntry
16+
{
17+
Id = "1",
18+
ResponseBody = "System.NullReferenceException: Object reference not set to an instance of an object.\r\n at Program.Main()"
19+
};
20+
var entry2 = new DebugEntry
21+
{
22+
Id = "2",
23+
ResponseBody = "System.NullReferenceException: Object reference not set to an instance of an object.\r\n at Program.Main()"
24+
};
25+
26+
store.Add(entry1);
27+
store.Add(entry2);
28+
29+
Assert.Single(store.ExceptionGroups);
30+
var group = store.ExceptionGroups.Values.First();
31+
Assert.Equal("System.NullReferenceException", group.Type);
32+
Assert.Equal("Object reference not set to an instance of an object.", group.SampleMessage);
33+
Assert.Equal(2, group.Count);
34+
}
35+
36+
[Fact]
37+
public void Add_different_messages_produces_separate_groups()
38+
{
39+
var store = new DebugEntryStore(new DebugProbeOptions());
40+
var entry1 = new DebugEntry
41+
{
42+
Id = "1",
43+
ResponseBody = "System.NullReferenceException: Object reference not set to an instance of an object.\r\n at Program.Main()"
44+
};
45+
var entry2 = new DebugEntry
46+
{
47+
Id = "2",
48+
ResponseBody = "System.InvalidOperationException: Operation is not valid due to the current state of the object.\r\n at Program.Main()"
49+
};
50+
51+
store.Add(entry1);
52+
store.Add(entry2);
53+
54+
Assert.Equal(2, store.ExceptionGroups.Count);
55+
}
56+
57+
[Fact]
58+
public void Add_messages_differing_only_in_dynamic_values_groups_them()
59+
{
60+
var store = new DebugEntryStore(new DebugProbeOptions());
61+
var entry1 = new DebugEntry
62+
{
63+
Id = "1",
64+
ResponseBody = "System.InvalidOperationException: Order 12345 failed.\r\n at Program.Main()"
65+
};
66+
var entry2 = new DebugEntry
67+
{
68+
Id = "2",
69+
ResponseBody = "System.InvalidOperationException: Order 67890 failed.\r\n at Program.Main()"
70+
};
71+
72+
store.Add(entry1);
73+
store.Add(entry2);
74+
75+
Assert.Single(store.ExceptionGroups);
76+
var group = store.ExceptionGroups.Values.First();
77+
Assert.Equal("System.InvalidOperationException", group.Type);
78+
Assert.Equal(2, group.Count);
79+
}
80+
}

DebugProbe.AspNetCore/Assets/css/debugprobe.css

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1293,7 +1293,8 @@ pre {
12931293
/* =========================
12941294
cURL Copy Button & Tooltip
12951295
========================= */
1296-
.curl-copy-btn {
1296+
.curl-copy-btn,
1297+
.csharp-copy-btn {
12971298
display: inline-flex;
12981299
align-items: center;
12991300
justify-content: center;
@@ -1308,13 +1309,15 @@ pre {
13081309
transition: all 0.15s ease;
13091310
}
13101311

1311-
.curl-copy-btn:hover {
1312+
.curl-copy-btn:hover,
1313+
.csharp-copy-btn:hover {
13121314
background: #f9fafb;
13131315
border-color: #d1d5db;
13141316
color: #111827;
13151317
}
13161318

1317-
.curl-copy-btn svg {
1319+
.curl-copy-btn svg,
1320+
.csharp-copy-btn svg {
13181321
width: 14px;
13191322
height: 14px;
13201323
stroke: currentColor;

DebugProbe.AspNetCore/Assets/js/debugprobe-ui.js

Lines changed: 77 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,82 @@ function buildCurlCommand(method, url, headers, body, isWindows) {
4343
return curlCmd;
4444
}
4545

46+
function showCopiedTooltip(btn) {
47+
const tooltip = document.createElement("div");
48+
tooltip.className = "copied-tooltip";
49+
tooltip.textContent = "Copied!";
50+
document.body.appendChild(tooltip);
51+
52+
const rect = btn.getBoundingClientRect();
53+
tooltip.style.left = (rect.left + window.scrollX + rect.width / 2) + "px";
54+
tooltip.style.top = (rect.top + window.scrollY) + "px";
55+
56+
setTimeout(() => {
57+
tooltip.remove();
58+
}, 1500);
59+
}
60+
61+
function escapeCSharpString(str) {
62+
if (!str) return "";
63+
return str
64+
.replace(/\\/g, "\\\\")
65+
.replace(/"/g, '\\"')
66+
.replace(/\r/g, '\\r')
67+
.replace(/\n/g, '\\n');
68+
}
69+
70+
function buildCSharpSnippet(method, url, headers, body) {
71+
const escapedUrl = escapeCSharpString(url);
72+
const methodFormatted = method.charAt(0).toUpperCase() + method.slice(1).toLowerCase();
73+
let snippet = `var request = new HttpRequestMessage(HttpMethod.${methodFormatted}, "${escapedUrl}");\n`;
74+
75+
for (const [key, value] of Object.entries(headers)) {
76+
if (!key || !value) continue;
77+
const trimmedVal = value.trim();
78+
if (trimmedVal === "[REDACTED]" || trimmedVal === "") continue;
79+
80+
snippet += `request.Headers.Add("${escapeCSharpString(key)}", "${escapeCSharpString(value)}");\n`;
81+
}
82+
83+
if (body && body.trim() !== "" && body !== "[Body too large]") {
84+
let contentType = "application/json";
85+
for (const [key, value] of Object.entries(headers)) {
86+
if (key.toLowerCase() === "content-type" && value && value.trim() !== "" && value.trim() !== "[REDACTED]") {
87+
contentType = value.split(";")[0].trim();
88+
break;
89+
}
90+
}
91+
snippet += `request.Content = new StringContent("${escapeCSharpString(body)}", Encoding.UTF8, "${escapeCSharpString(contentType)}");\n`;
92+
}
93+
94+
snippet += `var response = await httpClient.SendAsync(request);`;
95+
return snippet;
96+
}
97+
98+
function copyAsCSharp(btn) {
99+
const card = btn.closest(".trace-card");
100+
if (!card) return;
101+
102+
const method = card.dataset.method;
103+
const url = card.dataset.url;
104+
if (!method || !url) return;
105+
106+
let headers = {};
107+
try {
108+
headers = JSON.parse(card.dataset.headers || '{}');
109+
} catch (e) {
110+
// Fallback or ignore
111+
}
112+
113+
const body = card.dataset.body;
114+
115+
const snippet = buildCSharpSnippet(method, url, headers, body);
116+
117+
navigator.clipboard.writeText(snippet);
118+
119+
showCopiedTooltip(btn);
120+
}
121+
46122
function copyAsCurl(btn) {
47123
const card = btn.closest(".trace-card");
48124
if (!card) return;
@@ -67,19 +143,7 @@ function copyAsCurl(btn) {
67143

68144
navigator.clipboard.writeText(curlCmd);
69145

70-
// Show temporary "Copied!" tooltip
71-
const tooltip = document.createElement("div");
72-
tooltip.className = "copied-tooltip";
73-
tooltip.textContent = "Copied!";
74-
document.body.appendChild(tooltip);
75-
76-
const rect = btn.getBoundingClientRect();
77-
tooltip.style.left = (rect.left + window.scrollX + rect.width / 2) + "px";
78-
tooltip.style.top = (rect.top + window.scrollY) + "px";
79-
80-
setTimeout(() => {
81-
tooltip.remove();
82-
}, 1500);
146+
showCopiedTooltip(btn);
83147
}
84148

85149

DebugProbe.AspNetCore/Internal/Rendering/HtmlRenderer.cs

Lines changed: 60 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
using DebugProbe.AspNetCore.Internal.Resources;
33
using DebugProbe.AspNetCore.Internal.Utils;
44
using DebugProbe.AspNetCore.Models;
5+
using DebugProbe.AspNetCore.Storage;
56

67
namespace DebugProbe.AspNetCore.Internal.Rendering;
78

@@ -59,7 +60,52 @@ public static string RenderIndexPage(List<DebugEntry> items)
5960
var slowRequests = items.Count(x => x.DurationMs >= slowRequestThresholdMs);
6061
var errorRate = totalRequests == 0 ? 0 : items.Count(x => x.StatusCode >= 400) * 100d / totalRequests;
6162

62-
return BuildLayout(EmbeddedResources.Index
63+
var exceptionPanel = "";
64+
var store = DebugEntryStore.Instance;
65+
if (store != null && !store.ExceptionGroups.IsEmpty)
66+
{
67+
var sortedGroups = store.ExceptionGroups.Values
68+
.OrderByDescending(g => g.Count)
69+
.ToList();
70+
71+
var groupRows = string.Join("", sortedGroups.Select(g => $@"
72+
<tr>
73+
<td style=""font-weight: 600; color: #b42318; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;"" title=""{Encode(g.Type)}"">{Encode(g.Type)}</td>
74+
<td class=""request-path""><span class=""request-path-value"" title=""{Encode(g.SampleMessage)}"">{Encode(g.SampleMessage)}</span></td>
75+
<td><strong>{g.Count}</strong></td>
76+
<td>{g.LastSeen.ToLocalTime():yyyy-MM-dd HH:mm:ss}</td>
77+
</tr>"));
78+
79+
exceptionPanel = $@"
80+
<h3>Exception Groups</h3>
81+
<div class=""table-wrap"" style=""margin-bottom: 24px;"">
82+
<table style=""table-layout: fixed;"">
83+
<thead>
84+
<tr>
85+
<th style=""width: 25%;"">Type</th>
86+
<th style=""width: 45%;"">Sample Message</th>
87+
<th style=""width: 10%;"">Count</th>
88+
<th style=""width: 20%;"">Last Seen</th>
89+
</tr>
90+
</thead>
91+
<tbody>
92+
{groupRows}
93+
</tbody>
94+
</table>
95+
</div>";
96+
}
97+
98+
var pageHtml = EmbeddedResources.Index;
99+
if (!string.IsNullOrEmpty(exceptionPanel))
100+
{
101+
var idx = pageHtml.IndexOf("<div class=\"table-wrap\">");
102+
if (idx >= 0)
103+
{
104+
pageHtml = pageHtml.Insert(idx, exceptionPanel);
105+
}
106+
}
107+
108+
return BuildLayout(pageHtml
63109
.Replace("{{rows}}", rows)
64110
.Replace("{{total_count}}", items.Count.ToString())
65111
.Replace("{{method_options}}", methodOptions)
@@ -318,10 +364,10 @@ private static string BuildTraceCard(
318364
if (!string.IsNullOrWhiteSpace(dataHeaders)) dataAttrs += $" data-headers=\"{Encode(dataHeaders)}\"";
319365
if (!string.IsNullOrWhiteSpace(dataBody)) dataAttrs += $" data-body=\"{Encode(dataBody)}\"";
320366

321-
var copyCurlBtn = "";
367+
var copyBtns = "";
322368
if (!string.IsNullOrWhiteSpace(dataMethod))
323369
{
324-
copyCurlBtn = $@"
370+
copyBtns = $@"
325371
<button class=""curl-copy-btn""
326372
type=""button""
327373
title=""Copy as cURL""
@@ -331,6 +377,16 @@ private static string BuildTraceCard(
331377
<path d=""M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2""></path>
332378
<rect x=""8"" y=""2"" width=""8"" height=""4"" rx=""1"" ry=""1""></rect>
333379
</svg>
380+
</button>
381+
<button class=""csharp-copy-btn""
382+
type=""button""
383+
title=""Copy as C#""
384+
aria-label=""Copy as C#""
385+
onclick=""copyAsCSharp(this)"">
386+
<svg viewBox=""0 0 24 24"" aria-hidden=""true"">
387+
<path d=""M16 18l6-6-6-6""></path>
388+
<path d=""M8 6l-6 6 6 6""></path>
389+
</svg>
334390
</button>";
335391
}
336392

@@ -347,7 +403,7 @@ private static string BuildTraceCard(
347403
<div class=""trace-card-meta"">
348404
{status}
349405
{duration}
350-
{copyCurlBtn}
406+
{copyBtns}
351407
</div>
352408
</div>
353409
<div class=""trace-details"">
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
using System;
2+
3+
namespace DebugProbe.AspNetCore.Models;
4+
5+
/// <summary>
6+
/// Represents a grouped set of identical exceptions.
7+
/// </summary>
8+
public class ExceptionGroup
9+
{
10+
/// <summary>
11+
/// Gets or sets the fingerprint (hash) of the exception group.
12+
/// </summary>
13+
public string Fingerprint { get; set; } = default!;
14+
15+
/// <summary>
16+
/// Gets or sets the type of the exception.
17+
/// </summary>
18+
public string Type { get; set; } = default!;
19+
20+
/// <summary>
21+
/// Gets or sets a sample message from the exception.
22+
/// </summary>
23+
public string SampleMessage { get; set; } = default!;
24+
25+
/// <summary>
26+
/// Gets or sets the count of exceptions in this group.
27+
/// </summary>
28+
public int Count { get; set; }
29+
30+
/// <summary>
31+
/// Gets or sets the timestamp when the exception was last seen.
32+
/// </summary>
33+
public DateTimeOffset LastSeen { get; set; }
34+
}

0 commit comments

Comments
 (0)