Skip to content

Commit 331e539

Browse files
fix: resolve dashboard error trend indicators and add regression test…
PR fixes the bug in our dashboard's error trend indicators where they were constantly stuck or overcorrecting to a neutral (→) state, especially when older metrics data was evicted from the store. Now, the trend arrows dynamically and accurately reflect whether things are getting worse (↑ red) or improving (↓ green), even when partial or full data eviction happens. I've verified the UI manually, and it works flawlessly now!
2 parents 1593a57 + 98b8c4b commit 331e539

7 files changed

Lines changed: 351 additions & 5 deletions

File tree

Lines changed: 218 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,218 @@
1+
using System;
2+
using System.Collections.Generic;
3+
using DebugProbe.AspNetCore.Internal.Rendering;
4+
using DebugProbe.AspNetCore.Models;
5+
using DebugProbe.AspNetCore.Options;
6+
using Microsoft.Extensions.Options;
7+
using Xunit;
8+
using DebugProbe.AspNetCore.Storage;
9+
10+
namespace DebugProbe.AspNetCore.Tests.Rendering;
11+
12+
public class HtmlRendererTrendTests
13+
{
14+
[Fact]
15+
public void Render_index_page_with_no_data_generates_flat_sparkline()
16+
{
17+
var html = HtmlRenderer.RenderIndexPage([], new DebugProbeOptions { TrendLookbackMinutes = 30 });
18+
19+
// Flat sparkline check: Y values should all be 14
20+
Assert.Contains("14", html);
21+
Assert.Contains("trend-neutral", html);
22+
Assert.Contains("→", html);
23+
}
24+
25+
[Fact]
26+
public void Render_index_page_with_increased_errors_shows_trend_up()
27+
{
28+
var now = DateTimeOffset.UtcNow;
29+
var options = new DebugProbeOptions { TrendLookbackMinutes = 10 };
30+
31+
var items = new List<DebugEntry>
32+
{
33+
// Preceding half: [now - 10m, now - 5m). 1 request, 0 errors -> 0% error rate.
34+
new() { Id = "1", Timestamp = now.AddMinutes(-7), StatusCode = 200, Method = "GET", Path = "/api" },
35+
// Current half: [now - 5m, now]. 1 request, 1 error -> 100% error rate.
36+
new() { Id = "2", Timestamp = now.AddMinutes(-2), StatusCode = 500, Method = "GET", Path = "/api" }
37+
};
38+
39+
var html = HtmlRenderer.RenderIndexPage(items, options);
40+
41+
Assert.Contains("trend-up", html);
42+
Assert.Contains("↑", html);
43+
}
44+
45+
[Fact]
46+
public void Render_index_page_with_decreased_errors_shows_trend_down()
47+
{
48+
var now = DateTimeOffset.UtcNow;
49+
var options = new DebugProbeOptions { TrendLookbackMinutes = 10 };
50+
51+
var items = new List<DebugEntry>
52+
{
53+
// Preceding half: [now - 10m, now - 5m). 1 request, 1 error -> 100% error rate.
54+
new() { Id = "1", Timestamp = now.AddMinutes(-7), StatusCode = 500, Method = "GET", Path = "/api" },
55+
// Current half: [now - 5m, now]. 1 request, 0 errors -> 0% error rate.
56+
new() { Id = "2", Timestamp = now.AddMinutes(-2), StatusCode = 200, Method = "GET", Path = "/api" }
57+
};
58+
59+
var html = HtmlRenderer.RenderIndexPage(items, options);
60+
61+
Assert.Contains("trend-down", html);
62+
Assert.Contains("↓", html);
63+
}
64+
65+
[Fact]
66+
public void Render_index_page_with_unchanged_errors_shows_trend_neutral()
67+
{
68+
var now = DateTimeOffset.UtcNow;
69+
var options = new DebugProbeOptions { TrendLookbackMinutes = 10 };
70+
71+
var items = new List<DebugEntry>
72+
{
73+
// Preceding half: [now - 10m, now - 5m). 1 request, 0 errors -> 0% error rate.
74+
new() { Id = "1", Timestamp = now.AddMinutes(-7), StatusCode = 200, Method = "GET", Path = "/api" },
75+
// Current half: [now - 5m, now]. 1 request, 0 errors -> 0% error rate.
76+
new() { Id = "2", Timestamp = now.AddMinutes(-2), StatusCode = 200, Method = "GET", Path = "/api" }
77+
};
78+
79+
var html = HtmlRenderer.RenderIndexPage(items, options);
80+
81+
Assert.Contains("trend-neutral", html);
82+
Assert.Contains("→", html);
83+
}
84+
85+
[Fact]
86+
public void Render_index_page_scenario_a_and_b_regression_test()
87+
{
88+
var options = new DebugProbeOptions { MaxEntries = 10, TrendLookbackMinutes = 2 };
89+
90+
var now = DateTimeOffset.UtcNow;
91+
92+
// Scenario A: 8 successes at t = now - 90s, then 5 errors at t = now - 5s
93+
// Since MaxEntries = 10, 3 successes are evicted. 5 successes remain in previous window, 5 errors in current.
94+
// TotalB = 5 > 0, TotalA = 5 > 0.
95+
var itemsA = new List<DebugEntry>();
96+
for (int i = 0; i < 5; i++)
97+
{
98+
itemsA.Add(new DebugEntry { Id = $"success-{i}", Timestamp = now.AddSeconds(-90), StatusCode = 200, Method = "GET", Path = "/api" });
99+
}
100+
for (int i = 0; i < 5; i++)
101+
{
102+
itemsA.Add(new DebugEntry { Id = $"error-{i}", Timestamp = now.AddSeconds(-5), StatusCode = 500, Method = "GET", Path = "/api" });
103+
}
104+
105+
// Expected: should show Trend Up (↑) because previous window is valid and error rate went up from 0% to 100%
106+
var htmlA = HtmlRenderer.RenderIndexPage(itemsA, options);
107+
Assert.Contains("trend-up", htmlA);
108+
Assert.Contains("↑", htmlA);
109+
110+
// Scenario B: 8 recovery successes are sent, evicting all baseline successes and some errors.
111+
// Queue now has 8 recovery successes and 2 errors (all in current window).
112+
// Previous window is empty (TotalB = 0).
113+
var itemsB = new List<DebugEntry>();
114+
for (int i = 0; i < 2; i++)
115+
{
116+
itemsB.Add(new DebugEntry { Id = $"error-{i}", Timestamp = now.AddSeconds(-5), StatusCode = 500, Method = "GET", Path = "/api" });
117+
}
118+
for (int i = 0; i < 8; i++)
119+
{
120+
itemsB.Add(new DebugEntry { Id = $"recovery-{i}", Timestamp = now.AddSeconds(-2), StatusCode = 200, Method = "GET", Path = "/api" });
121+
}
122+
123+
// Expected: should show Trend Neutral (→) because previous window is empty
124+
var htmlB = HtmlRenderer.RenderIndexPage(itemsB, options);
125+
Assert.Contains("trend-neutral", htmlB);
126+
Assert.Contains("→", htmlB);
127+
}
128+
129+
[Fact]
130+
public void Render_index_page_previous_window_evicted_shows_neutral()
131+
{
132+
var options = new DebugProbeOptions { MaxEntries = 10, TrendLookbackMinutes = 2 };
133+
var items = new List<DebugEntry>();
134+
135+
var now = DateTimeOffset.UtcNow;
136+
// All baseline entries from previous window are evicted. Previous window has 0 entries.
137+
for (int i = 0; i < 10; i++)
138+
{
139+
items.Add(new DebugEntry { Id = $"recovery-{i}", Timestamp = now.AddSeconds(-2), StatusCode = 200, Method = "GET", Path = "/api" });
140+
}
141+
142+
var html = HtmlRenderer.RenderIndexPage(items, options);
143+
Assert.Contains("trend-neutral", html);
144+
Assert.Contains("→", html);
145+
}
146+
147+
[Fact]
148+
public void Render_index_page_previous_window_no_traffic_shows_neutral()
149+
{
150+
var options = new DebugProbeOptions { MaxEntries = 100, TrendLookbackMinutes = 2 };
151+
var items = new List<DebugEntry>();
152+
153+
var now = DateTimeOffset.UtcNow;
154+
// Only 5 errors are sent now. No traffic occurred in the previous window.
155+
for (int i = 0; i < 5; i++)
156+
{
157+
items.Add(new DebugEntry { Id = $"error-{i}", Timestamp = now.AddSeconds(-5), StatusCode = 500, Method = "GET", Path = "/api" });
158+
}
159+
160+
var html = HtmlRenderer.RenderIndexPage(items, options);
161+
Assert.Contains("trend-neutral", html);
162+
Assert.Contains("→", html);
163+
}
164+
165+
[Fact]
166+
public void Render_index_page_happy_path_with_no_eviction_flips_trend()
167+
{
168+
var options = new DebugProbeOptions { MaxEntries = 100, TrendLookbackMinutes = 2 };
169+
var store = new DebugEntryStore(options);
170+
171+
var now = DateTimeOffset.UtcNow;
172+
// Phase 1: 8 successes at t = now - 90s (previous window)
173+
for (int i = 0; i < 8; i++)
174+
{
175+
store.Add(new DebugEntry { Id = $"success-{i}", Timestamp = now.AddSeconds(-90), StatusCode = 200, Method = "GET", Path = "/api" });
176+
}
177+
178+
// Phase 2: 5 errors at t = now - 5s (current window)
179+
for (int i = 0; i < 5; i++)
180+
{
181+
store.Add(new DebugEntry { Id = $"error-{i}", Timestamp = now.AddSeconds(-5), StatusCode = 500, Method = "GET", Path = "/api" });
182+
}
183+
184+
var htmlErrors = HtmlRenderer.RenderIndexPage(store.GetAll(), options);
185+
Assert.Contains("trend-up", htmlErrors);
186+
Assert.Contains("↑", htmlErrors);
187+
188+
store.Clear();
189+
190+
// Phase 3: 5 errors at t = now - 90s (previous window)
191+
for (int i = 0; i < 5; i++)
192+
{
193+
store.Add(new DebugEntry { Id = $"error-{i}", Timestamp = now.AddSeconds(-90), StatusCode = 500, Method = "GET", Path = "/api" });
194+
}
195+
196+
// Phase 4: 8 successes at t = now - 5s (current window)
197+
for (int i = 0; i < 8; i++)
198+
{
199+
store.Add(new DebugEntry { Id = $"success-{i}", Timestamp = now.AddSeconds(-5), StatusCode = 200, Method = "GET", Path = "/api" });
200+
}
201+
202+
var htmlRecovery = HtmlRenderer.RenderIndexPage(store.GetAll(), options);
203+
Assert.Contains("trend-down", htmlRecovery);
204+
Assert.Contains("↓", htmlRecovery);
205+
}
206+
207+
[Fact]
208+
public void Options_validator_rejects_trend_lookback_less_than_two()
209+
{
210+
var validator = new DebugProbeOptionsValidator();
211+
var options = new DebugProbeOptions { TrendLookbackMinutes = 1 };
212+
213+
var result = validator.Validate(null, options);
214+
215+
Assert.True(result.Failed);
216+
Assert.Contains("TrendLookbackMinutes must be greater than or equal to 2", result.FailureMessage);
217+
}
218+
}

DebugProbe.AspNetCore/Assets/css/debugprobe.css

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,7 @@ h4 {
7777

7878
.stats-bar {
7979
display: grid;
80-
grid-template-columns: repeat(4, minmax(150px, 1fr));
80+
grid-template-columns: repeat(5, minmax(150px, 1fr));
8181
gap: 10px;
8282
margin-bottom: 14px;
8383
}
@@ -108,6 +108,27 @@ h4 {
108108
text-transform: uppercase;
109109
}
110110

111+
.trend-arrow {
112+
display: inline-block;
113+
font-size: 16px;
114+
margin-left: 4px;
115+
vertical-align: middle;
116+
line-height: 1;
117+
}
118+
119+
.trend-up {
120+
color: #e74c3c;
121+
}
122+
123+
.trend-down {
124+
color: #27ae60;
125+
}
126+
127+
.trend-neutral {
128+
color: #9ca3af;
129+
}
130+
131+
111132
.filters input,
112133
.filters select {
113134
min-height: 36px;

DebugProbe.AspNetCore/Assets/html/index.html

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,9 +20,15 @@ <h2>Requests</h2>
2020
<span>Over 1s</span>
2121
</div>
2222
<div class="stat-tile">
23-
<strong>{{error_rate}}</strong>
23+
<strong>{{error_rate}}{{error_trend}}</strong>
2424
<span>Errors</span>
2525
</div>
26+
<div class="stat-tile">
27+
<div style="display: flex; align-items: center; justify-content: center; height: 28px; width: 120px;">
28+
{{sparkline}}
29+
</div>
30+
<span>Activity</span>
31+
</div>
2632
</div>
2733

2834
<div class="filters" aria-label="Request filters">

DebugProbe.AspNetCore/Internal/Rendering/HtmlRenderer.cs

Lines changed: 87 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -64,8 +64,91 @@ public static string RenderIndexPage(List<DebugEntry> items, DebugProbeOptions?
6464
var slowRequests = slowRequestThresholdMs > 0 ? items.Count(x => x.DurationMs >= slowRequestThresholdMs) : 0;
6565
var errorRate = totalRequests == 0 ? 0 : items.Count(x => x.StatusCode >= 400) * 100d / totalRequests;
6666

67-
var exceptionPanel = "";
67+
// Trend calculations
6868
var store = DebugEntryStore.Instance;
69+
var now = DateTimeOffset.UtcNow;
70+
var limitTime = now.AddMinutes(-options.TrendLookbackMinutes);
71+
var midTime = now.AddMinutes(-options.TrendLookbackMinutes / 2.0);
72+
73+
int[] buckets = new int[options.TrendLookbackMinutes];
74+
int totalA = 0;
75+
int errorsA = 0;
76+
int totalB = 0;
77+
int errorsB = 0;
78+
79+
foreach (var entry in items)
80+
{
81+
var t = entry.Timestamp;
82+
var elapsed = now - t;
83+
if (elapsed.TotalMinutes < options.TrendLookbackMinutes)
84+
{
85+
double mins = Math.Max(0.0, elapsed.TotalMinutes);
86+
int bucketIndex = options.TrendLookbackMinutes - 1 - (int)Math.Floor(mins);
87+
if (bucketIndex >= 0 && bucketIndex < options.TrendLookbackMinutes)
88+
{
89+
buckets[bucketIndex]++;
90+
}
91+
}
92+
93+
if (t >= midTime)
94+
{
95+
totalA++;
96+
if (entry.StatusCode >= 400)
97+
{
98+
errorsA++;
99+
}
100+
}
101+
else if (t >= limitTime && t < midTime)
102+
{
103+
totalB++;
104+
if (entry.StatusCode >= 400)
105+
{
106+
errorsB++;
107+
}
108+
}
109+
}
110+
111+
int maxVal = buckets.Max();
112+
var pointsList = new List<string>();
113+
for (int i = 0; i < options.TrendLookbackMinutes; i++)
114+
{
115+
double x = (double)i / (options.TrendLookbackMinutes - 1) * 120.0;
116+
double y = maxVal == 0 ? 14.0 : 26.0 - ((double)buckets[i] / maxVal * 24.0);
117+
pointsList.Add($"{x:0.##},{y:0.##}");
118+
}
119+
string pointsString = string.Join(" ", pointsList);
120+
121+
string sparklineSvg = $@"<svg width=""120"" height=""28"" viewBox=""0 0 120 28"" style=""overflow: visible;"" xmlns=""http://www.w3.org/2000/svg""><polyline fill=""none"" stroke=""#6c5ce7"" stroke-width=""2"" stroke-linecap=""round"" stroke-linejoin=""round"" points=""{pointsString}"" /></svg>";
122+
123+
double errorRateA = totalA == 0 ? 0.0 : (double)errorsA / totalA;
124+
double errorRateB = totalB == 0 ? 0.0 : (double)errorsB / totalB;
125+
126+
string trendArrow;
127+
string arrowClass;
128+
if (totalA == 0 || totalB == 0)
129+
{
130+
trendArrow = "→";
131+
arrowClass = "trend-neutral";
132+
}
133+
else if (errorRateA > errorRateB)
134+
{
135+
trendArrow = "↑";
136+
arrowClass = "trend-up";
137+
}
138+
else if (errorRateA < errorRateB)
139+
{
140+
trendArrow = "↓";
141+
arrowClass = "trend-down";
142+
}
143+
else
144+
{
145+
trendArrow = "→";
146+
arrowClass = "trend-neutral";
147+
}
148+
149+
string errorTrendHtml = $" <span class=\"trend-arrow {arrowClass}\" title=\"vs preceding period\">{trendArrow}</span>";
150+
151+
var exceptionPanel = "";
69152
if (store != null && !store.ExceptionGroups.IsEmpty)
70153
{
71154
var sortedGroups = store.ExceptionGroups.Values
@@ -116,7 +199,9 @@ public static string RenderIndexPage(List<DebugEntry> items, DebugProbeOptions?
116199
.Replace("{{total_requests}}", FormatCompactNumber(totalRequests))
117200
.Replace("{{avg_response_time}}", $"{averageResponseMs} ms")
118201
.Replace("{{slow_requests}}", FormatCompactNumber(slowRequests))
119-
.Replace("{{error_rate}}", $"{errorRate:0.#}%"));
202+
.Replace("{{error_rate}}", $"{errorRate:0.#}%")
203+
.Replace("{{error_trend}}", errorTrendHtml)
204+
.Replace("{{sparkline}}", sparklineSvg));
120205
}
121206

122207
public static string RenderDetailsPage(DebugEntry x, DebugEnvironment e, string req, string res, DebugProbeOptions? options = null)

DebugProbe.AspNetCore/Options/DebugProbeOptions.cs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,12 @@ public int MaxBodyCaptureSizeKb
3333
/// </summary>
3434
public int SlowRequestThresholdMs { get; set; } = 1000;
3535

36+
/// <summary>
37+
/// Lookback window in minutes for the request rate sparkline and error rate trend.
38+
/// Defaults to 30. Must be greater than or equal to 2.
39+
/// </summary>
40+
public int TrendLookbackMinutes { get; set; } = 2;
41+
3642
/// <summary>
3743
/// Allows compare operations to target localhost and private network addresses.
3844
/// Defaults to true in Development and false in other environments unless explicitly configured.

0 commit comments

Comments
 (0)