diff --git a/DebugProbe.AspNetCore.Tests/Rendering/HtmlRendererTests.cs b/DebugProbe.AspNetCore.Tests/Rendering/HtmlRendererTests.cs index 3435446..1fce703 100644 --- a/DebugProbe.AspNetCore.Tests/Rendering/HtmlRendererTests.cs +++ b/DebugProbe.AspNetCore.Tests/Rendering/HtmlRendererTests.cs @@ -204,6 +204,44 @@ public void Details_page_renders_curl_copy_attributes_and_buttons() Assert.Equal(2, occurrences); } + [Fact] + public void Details_page_renders_csharp_copy_attributes_and_buttons() + { + var entry = CreateEntry(); + entry.OutgoingRequests.Add(new DebugOutgoingRequest + { + Method = "PUT", + Url = "https://external-api.test/v1/update", + StatusCode = 200, + DurationMs = 120, + RequestBody = "{\"name\":\"John\"}", + RequestHeaders = new Dictionary { ["Authorization"] = "Bearer token" } + }); + + var html = HtmlRenderer.RenderDetailsPage( + entry, + CreateEnvironment(), + "{\"request\":true}", + "{\"response\":true}"); + + // 1. Verify Incoming Request Card attributes and button + Assert.Contains("data-method=\"POST\"", html); + Assert.Contains("data-url=\"http://example.test/orders?id=10\"", html); + Assert.Contains("data-headers=\"{"X-Test":"yes"}\"", html); + Assert.Contains("data-body=\"{"request":true}\"", html); + Assert.Contains("class=\"csharp-copy-btn\"", html); + + // 2. Verify Outgoing Request Card attributes and button + Assert.Contains("data-method=\"PUT\"", html); + Assert.Contains("data-url=\"https://external-api.test/v1/update\"", html); + Assert.Contains("data-headers=\"{"Authorization":"Bearer token"}\"", html); + Assert.Contains("data-body=\"{"name":"John"}\"", html); + + // 3. Verify Response Card has no C# copy button (only 2 copy C# buttons in total should exist in HTML markup) + var occurrences = (html.Length - html.Replace("class=\"csharp-copy-btn\"", "").Length) / "class=\"csharp-copy-btn\"".Length; + Assert.Equal(2, occurrences); + } + private static DebugEntry CreateEntry() { return new DebugEntry diff --git a/DebugProbe.AspNetCore.Tests/Storage/DebugEntryStoreTests.cs b/DebugProbe.AspNetCore.Tests/Storage/DebugEntryStoreTests.cs new file mode 100644 index 0000000..b43aa7a --- /dev/null +++ b/DebugProbe.AspNetCore.Tests/Storage/DebugEntryStoreTests.cs @@ -0,0 +1,80 @@ +using System.Linq; +using DebugProbe.AspNetCore.Models; +using DebugProbe.AspNetCore.Options; +using DebugProbe.AspNetCore.Storage; +using Xunit; + +namespace DebugProbe.AspNetCore.Tests.Storage; + +public class DebugEntryStoreTests +{ + [Fact] + public void Add_identical_type_and_message_increments_count() + { + var store = new DebugEntryStore(new DebugProbeOptions()); + var entry1 = new DebugEntry + { + Id = "1", + ResponseBody = "System.NullReferenceException: Object reference not set to an instance of an object.\r\n at Program.Main()" + }; + var entry2 = new DebugEntry + { + Id = "2", + ResponseBody = "System.NullReferenceException: Object reference not set to an instance of an object.\r\n at Program.Main()" + }; + + store.Add(entry1); + store.Add(entry2); + + Assert.Single(store.ExceptionGroups); + var group = store.ExceptionGroups.Values.First(); + Assert.Equal("System.NullReferenceException", group.Type); + Assert.Equal("Object reference not set to an instance of an object.", group.SampleMessage); + Assert.Equal(2, group.Count); + } + + [Fact] + public void Add_different_messages_produces_separate_groups() + { + var store = new DebugEntryStore(new DebugProbeOptions()); + var entry1 = new DebugEntry + { + Id = "1", + ResponseBody = "System.NullReferenceException: Object reference not set to an instance of an object.\r\n at Program.Main()" + }; + var entry2 = new DebugEntry + { + Id = "2", + ResponseBody = "System.InvalidOperationException: Operation is not valid due to the current state of the object.\r\n at Program.Main()" + }; + + store.Add(entry1); + store.Add(entry2); + + Assert.Equal(2, store.ExceptionGroups.Count); + } + + [Fact] + public void Add_messages_differing_only_in_dynamic_values_groups_them() + { + var store = new DebugEntryStore(new DebugProbeOptions()); + var entry1 = new DebugEntry + { + Id = "1", + ResponseBody = "System.InvalidOperationException: Order 12345 failed.\r\n at Program.Main()" + }; + var entry2 = new DebugEntry + { + Id = "2", + ResponseBody = "System.InvalidOperationException: Order 67890 failed.\r\n at Program.Main()" + }; + + store.Add(entry1); + store.Add(entry2); + + Assert.Single(store.ExceptionGroups); + var group = store.ExceptionGroups.Values.First(); + Assert.Equal("System.InvalidOperationException", group.Type); + Assert.Equal(2, group.Count); + } +} diff --git a/DebugProbe.AspNetCore/Assets/css/debugprobe.css b/DebugProbe.AspNetCore/Assets/css/debugprobe.css index 0e09b4f..012bd62 100644 --- a/DebugProbe.AspNetCore/Assets/css/debugprobe.css +++ b/DebugProbe.AspNetCore/Assets/css/debugprobe.css @@ -1293,7 +1293,8 @@ pre { /* ========================= cURL Copy Button & Tooltip ========================= */ -.curl-copy-btn { +.curl-copy-btn, +.csharp-copy-btn { display: inline-flex; align-items: center; justify-content: center; @@ -1308,13 +1309,15 @@ pre { transition: all 0.15s ease; } -.curl-copy-btn:hover { +.curl-copy-btn:hover, +.csharp-copy-btn:hover { background: #f9fafb; border-color: #d1d5db; color: #111827; } -.curl-copy-btn svg { +.curl-copy-btn svg, +.csharp-copy-btn svg { width: 14px; height: 14px; stroke: currentColor; diff --git a/DebugProbe.AspNetCore/Assets/js/debugprobe-ui.js b/DebugProbe.AspNetCore/Assets/js/debugprobe-ui.js index 8951de1..926c316 100644 --- a/DebugProbe.AspNetCore/Assets/js/debugprobe-ui.js +++ b/DebugProbe.AspNetCore/Assets/js/debugprobe-ui.js @@ -43,6 +43,82 @@ function buildCurlCommand(method, url, headers, body, isWindows) { return curlCmd; } +function showCopiedTooltip(btn) { + const tooltip = document.createElement("div"); + tooltip.className = "copied-tooltip"; + tooltip.textContent = "Copied!"; + document.body.appendChild(tooltip); + + const rect = btn.getBoundingClientRect(); + tooltip.style.left = (rect.left + window.scrollX + rect.width / 2) + "px"; + tooltip.style.top = (rect.top + window.scrollY) + "px"; + + setTimeout(() => { + tooltip.remove(); + }, 1500); +} + +function escapeCSharpString(str) { + if (!str) return ""; + return str + .replace(/\\/g, "\\\\") + .replace(/"/g, '\\"') + .replace(/\r/g, '\\r') + .replace(/\n/g, '\\n'); +} + +function buildCSharpSnippet(method, url, headers, body) { + const escapedUrl = escapeCSharpString(url); + const methodFormatted = method.charAt(0).toUpperCase() + method.slice(1).toLowerCase(); + let snippet = `var request = new HttpRequestMessage(HttpMethod.${methodFormatted}, "${escapedUrl}");\n`; + + for (const [key, value] of Object.entries(headers)) { + if (!key || !value) continue; + const trimmedVal = value.trim(); + if (trimmedVal === "[REDACTED]" || trimmedVal === "") continue; + + snippet += `request.Headers.Add("${escapeCSharpString(key)}", "${escapeCSharpString(value)}");\n`; + } + + if (body && body.trim() !== "" && body !== "[Body too large]") { + let contentType = "application/json"; + for (const [key, value] of Object.entries(headers)) { + if (key.toLowerCase() === "content-type" && value && value.trim() !== "" && value.trim() !== "[REDACTED]") { + contentType = value.split(";")[0].trim(); + break; + } + } + snippet += `request.Content = new StringContent("${escapeCSharpString(body)}", Encoding.UTF8, "${escapeCSharpString(contentType)}");\n`; + } + + snippet += `var response = await httpClient.SendAsync(request);`; + return snippet; +} + +function copyAsCSharp(btn) { + const card = btn.closest(".trace-card"); + if (!card) return; + + const method = card.dataset.method; + const url = card.dataset.url; + if (!method || !url) return; + + let headers = {}; + try { + headers = JSON.parse(card.dataset.headers || '{}'); + } catch (e) { + // Fallback or ignore + } + + const body = card.dataset.body; + + const snippet = buildCSharpSnippet(method, url, headers, body); + + navigator.clipboard.writeText(snippet); + + showCopiedTooltip(btn); +} + function copyAsCurl(btn) { const card = btn.closest(".trace-card"); if (!card) return; @@ -67,19 +143,7 @@ function copyAsCurl(btn) { navigator.clipboard.writeText(curlCmd); - // Show temporary "Copied!" tooltip - const tooltip = document.createElement("div"); - tooltip.className = "copied-tooltip"; - tooltip.textContent = "Copied!"; - document.body.appendChild(tooltip); - - const rect = btn.getBoundingClientRect(); - tooltip.style.left = (rect.left + window.scrollX + rect.width / 2) + "px"; - tooltip.style.top = (rect.top + window.scrollY) + "px"; - - setTimeout(() => { - tooltip.remove(); - }, 1500); + showCopiedTooltip(btn); } diff --git a/DebugProbe.AspNetCore/Internal/Rendering/HtmlRenderer.cs b/DebugProbe.AspNetCore/Internal/Rendering/HtmlRenderer.cs index 6c62ae2..190bd4e 100644 --- a/DebugProbe.AspNetCore/Internal/Rendering/HtmlRenderer.cs +++ b/DebugProbe.AspNetCore/Internal/Rendering/HtmlRenderer.cs @@ -2,6 +2,7 @@ using DebugProbe.AspNetCore.Internal.Resources; using DebugProbe.AspNetCore.Internal.Utils; using DebugProbe.AspNetCore.Models; +using DebugProbe.AspNetCore.Storage; namespace DebugProbe.AspNetCore.Internal.Rendering; @@ -59,7 +60,52 @@ public static string RenderIndexPage(List items) var slowRequests = items.Count(x => x.DurationMs >= slowRequestThresholdMs); var errorRate = totalRequests == 0 ? 0 : items.Count(x => x.StatusCode >= 400) * 100d / totalRequests; - return BuildLayout(EmbeddedResources.Index + var exceptionPanel = ""; + var store = DebugEntryStore.Instance; + if (store != null && !store.ExceptionGroups.IsEmpty) + { + var sortedGroups = store.ExceptionGroups.Values + .OrderByDescending(g => g.Count) + .ToList(); + + var groupRows = string.Join("", sortedGroups.Select(g => $@" + + {Encode(g.Type)} + {Encode(g.SampleMessage)} + {g.Count} + {g.LastSeen.ToLocalTime():yyyy-MM-dd HH:mm:ss} + ")); + + exceptionPanel = $@" +

Exception Groups

+
+ + + + + + + + + + + {groupRows} + +
TypeSample MessageCountLast Seen
+
"; + } + + var pageHtml = EmbeddedResources.Index; + if (!string.IsNullOrEmpty(exceptionPanel)) + { + var idx = pageHtml.IndexOf("
"); + if (idx >= 0) + { + pageHtml = pageHtml.Insert(idx, exceptionPanel); + } + } + + return BuildLayout(pageHtml .Replace("{{rows}}", rows) .Replace("{{total_count}}", items.Count.ToString()) .Replace("{{method_options}}", methodOptions) @@ -318,10 +364,10 @@ private static string BuildTraceCard( if (!string.IsNullOrWhiteSpace(dataHeaders)) dataAttrs += $" data-headers=\"{Encode(dataHeaders)}\""; if (!string.IsNullOrWhiteSpace(dataBody)) dataAttrs += $" data-body=\"{Encode(dataBody)}\""; - var copyCurlBtn = ""; + var copyBtns = ""; if (!string.IsNullOrWhiteSpace(dataMethod)) { - copyCurlBtn = $@" + copyBtns = $@" + "; } @@ -347,7 +403,7 @@ private static string BuildTraceCard(
{status} {duration} - {copyCurlBtn} + {copyBtns}
diff --git a/DebugProbe.AspNetCore/Models/ExceptionGroup.cs b/DebugProbe.AspNetCore/Models/ExceptionGroup.cs new file mode 100644 index 0000000..579bc92 --- /dev/null +++ b/DebugProbe.AspNetCore/Models/ExceptionGroup.cs @@ -0,0 +1,34 @@ +using System; + +namespace DebugProbe.AspNetCore.Models; + +/// +/// Represents a grouped set of identical exceptions. +/// +public class ExceptionGroup +{ + /// + /// Gets or sets the fingerprint (hash) of the exception group. + /// + public string Fingerprint { get; set; } = default!; + + /// + /// Gets or sets the type of the exception. + /// + public string Type { get; set; } = default!; + + /// + /// Gets or sets a sample message from the exception. + /// + public string SampleMessage { get; set; } = default!; + + /// + /// Gets or sets the count of exceptions in this group. + /// + public int Count { get; set; } + + /// + /// Gets or sets the timestamp when the exception was last seen. + /// + public DateTimeOffset LastSeen { get; set; } +} diff --git a/DebugProbe.AspNetCore/Storage/DebugEntryStore.cs b/DebugProbe.AspNetCore/Storage/DebugEntryStore.cs index e9c0a8c..21e9016 100644 --- a/DebugProbe.AspNetCore/Storage/DebugEntryStore.cs +++ b/DebugProbe.AspNetCore/Storage/DebugEntryStore.cs @@ -1,6 +1,9 @@ -using System.Collections.Concurrent; +using System.Collections.Concurrent; using System.Globalization; using System.Reflection; +using System.Security.Cryptography; +using System.Text; +using System.Text.RegularExpressions; using DebugProbe.AspNetCore.Internal.Utils; using DebugProbe.AspNetCore.Models; using DebugProbe.AspNetCore.Options; @@ -12,6 +15,19 @@ namespace DebugProbe.AspNetCore.Storage; /// public class DebugEntryStore { + private static readonly Regex GuidRegex = new(@"\b[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}\b", RegexOptions.Compiled); + private static readonly Regex NumberRegex = new(@"\b\d+(\.\d+)?\b", RegexOptions.Compiled); + + /// + /// Gets the static instance of DebugEntryStore. + /// + public static DebugEntryStore? Instance { get; private set; } + + /// + /// Gets the exception groups. + /// + public ConcurrentDictionary ExceptionGroups { get; } = new(); + /// /// Gets environment information for the current application. /// @@ -22,6 +38,7 @@ public class DebugEntryStore public DebugEntryStore(DebugProbeOptions options) { + Instance = this; _limit = options.MaxEntries; Environment = new DebugEnvironment @@ -41,8 +58,33 @@ public void Add(DebugEntry entry) { _queue.Enqueue(entry); + if (TryParseException(entry.ResponseBody, out var type, out var message)) + { + var normalizedMessage = NormalizeMessage(message); + var fingerprint = ComputeHash(type + normalizedMessage); + + ExceptionGroups.AddOrUpdate(fingerprint, + key => new ExceptionGroup + { + Fingerprint = fingerprint, + Type = type, + SampleMessage = message, + Count = 1, + LastSeen = DateTimeOffset.UtcNow + }, + (key, existing) => new ExceptionGroup + { + Fingerprint = existing.Fingerprint, + Type = existing.Type, + SampleMessage = existing.SampleMessage, + Count = existing.Count + 1, + LastSeen = DateTimeOffset.UtcNow + }); + } + while (_queue.Count > _limit) { + // ExceptionGroups counts are a running tally and must NOT be decremented on MaxEntries eviction. _queue.TryDequeue(out _); } } @@ -60,6 +102,63 @@ public List GetAll() public void Clear() { while (_queue.TryDequeue(out _)) { } + ExceptionGroups.Clear(); + } + + private static bool TryParseException(string? body, out string type, out string message) + { + type = string.Empty; + message = string.Empty; + + if (string.IsNullOrWhiteSpace(body)) + { + return false; + } + + var firstLine = body.Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries).FirstOrDefault(); + if (string.IsNullOrWhiteSpace(firstLine)) + { + return false; + } + + var colonIndex = firstLine.IndexOf(':'); + if (colonIndex <= 0) + { + var trimmed = firstLine.Trim(); + if (!trimmed.Contains(' ') && trimmed.EndsWith("Exception")) + { + type = trimmed; + message = string.Empty; + return true; + } + return false; + } + + var potentialType = firstLine[..colonIndex].Trim(); + if (potentialType.Contains(' ') || !potentialType.EndsWith("Exception")) + { + return false; + } + + type = potentialType; + message = firstLine[(colonIndex + 1)..].Trim(); + return true; + } + + private static string NormalizeMessage(string message) + { + if (string.IsNullOrEmpty(message)) + return string.Empty; + + var normalized = GuidRegex.Replace(message, "*"); + normalized = NumberRegex.Replace(normalized, "*"); + return normalized; + } + + private static string ComputeHash(string input) + { + var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(input)); + return Convert.ToHexString(bytes); } private static string GetDateFormat()