Skip to content

Commit 178e7b8

Browse files
committed
Add exception fingerprinting and grouping feature
1 parent d81cb40 commit 178e7b8

4 files changed

Lines changed: 261 additions & 2 deletions

File tree

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/Internal/Rendering/HtmlRenderer.cs

Lines changed: 47 additions & 1 deletion
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)
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+
}

DebugProbe.AspNetCore/Storage/DebugEntryStore.cs

Lines changed: 100 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
1-
using System.Collections.Concurrent;
1+
using System.Collections.Concurrent;
22
using System.Globalization;
33
using System.Reflection;
4+
using System.Security.Cryptography;
5+
using System.Text;
6+
using System.Text.RegularExpressions;
47
using DebugProbe.AspNetCore.Internal.Utils;
58
using DebugProbe.AspNetCore.Models;
69
using DebugProbe.AspNetCore.Options;
@@ -12,6 +15,19 @@ namespace DebugProbe.AspNetCore.Storage;
1215
/// </summary>
1316
public class DebugEntryStore
1417
{
18+
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);
19+
private static readonly Regex NumberRegex = new(@"\b\d+(\.\d+)?\b", RegexOptions.Compiled);
20+
21+
/// <summary>
22+
/// Gets the static instance of DebugEntryStore.
23+
/// </summary>
24+
public static DebugEntryStore? Instance { get; private set; }
25+
26+
/// <summary>
27+
/// Gets the exception groups.
28+
/// </summary>
29+
public ConcurrentDictionary<string, ExceptionGroup> ExceptionGroups { get; } = new();
30+
1531
/// <summary>
1632
/// Gets environment information for the current application.
1733
/// </summary>
@@ -22,6 +38,7 @@ public class DebugEntryStore
2238

2339
public DebugEntryStore(DebugProbeOptions options)
2440
{
41+
Instance = this;
2542
_limit = options.MaxEntries;
2643

2744
Environment = new DebugEnvironment
@@ -41,8 +58,33 @@ public void Add(DebugEntry entry)
4158
{
4259
_queue.Enqueue(entry);
4360

61+
if (TryParseException(entry.ResponseBody, out var type, out var message))
62+
{
63+
var normalizedMessage = NormalizeMessage(message);
64+
var fingerprint = ComputeHash(type + normalizedMessage);
65+
66+
ExceptionGroups.AddOrUpdate(fingerprint,
67+
key => new ExceptionGroup
68+
{
69+
Fingerprint = fingerprint,
70+
Type = type,
71+
SampleMessage = message,
72+
Count = 1,
73+
LastSeen = DateTimeOffset.UtcNow
74+
},
75+
(key, existing) => new ExceptionGroup
76+
{
77+
Fingerprint = existing.Fingerprint,
78+
Type = existing.Type,
79+
SampleMessage = existing.SampleMessage,
80+
Count = existing.Count + 1,
81+
LastSeen = DateTimeOffset.UtcNow
82+
});
83+
}
84+
4485
while (_queue.Count > _limit)
4586
{
87+
// ExceptionGroups counts are a running tally and must NOT be decremented on MaxEntries eviction.
4688
_queue.TryDequeue(out _);
4789
}
4890
}
@@ -60,6 +102,63 @@ public List<DebugEntry> GetAll()
60102
public void Clear()
61103
{
62104
while (_queue.TryDequeue(out _)) { }
105+
ExceptionGroups.Clear();
106+
}
107+
108+
private static bool TryParseException(string? body, out string type, out string message)
109+
{
110+
type = string.Empty;
111+
message = string.Empty;
112+
113+
if (string.IsNullOrWhiteSpace(body))
114+
{
115+
return false;
116+
}
117+
118+
var firstLine = body.Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries).FirstOrDefault();
119+
if (string.IsNullOrWhiteSpace(firstLine))
120+
{
121+
return false;
122+
}
123+
124+
var colonIndex = firstLine.IndexOf(':');
125+
if (colonIndex <= 0)
126+
{
127+
var trimmed = firstLine.Trim();
128+
if (!trimmed.Contains(' ') && trimmed.EndsWith("Exception"))
129+
{
130+
type = trimmed;
131+
message = string.Empty;
132+
return true;
133+
}
134+
return false;
135+
}
136+
137+
var potentialType = firstLine[..colonIndex].Trim();
138+
if (potentialType.Contains(' ') || !potentialType.EndsWith("Exception"))
139+
{
140+
return false;
141+
}
142+
143+
type = potentialType;
144+
message = firstLine[(colonIndex + 1)..].Trim();
145+
return true;
146+
}
147+
148+
private static string NormalizeMessage(string message)
149+
{
150+
if (string.IsNullOrEmpty(message))
151+
return string.Empty;
152+
153+
var normalized = GuidRegex.Replace(message, "*");
154+
normalized = NumberRegex.Replace(normalized, "*");
155+
return normalized;
156+
}
157+
158+
private static string ComputeHash(string input)
159+
{
160+
var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(input));
161+
return Convert.ToHexString(bytes);
63162
}
64163

65164
private static string GetDateFormat()

0 commit comments

Comments
 (0)