Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
218 changes: 218 additions & 0 deletions DebugProbe.AspNetCore.Tests/Rendering/HtmlRendererTrendTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,218 @@
using System;
using System.Collections.Generic;
using DebugProbe.AspNetCore.Internal.Rendering;
using DebugProbe.AspNetCore.Models;
using DebugProbe.AspNetCore.Options;
using Microsoft.Extensions.Options;
using Xunit;
using DebugProbe.AspNetCore.Storage;

namespace DebugProbe.AspNetCore.Tests.Rendering;

public class HtmlRendererTrendTests
{
[Fact]
public void Render_index_page_with_no_data_generates_flat_sparkline()
{
var html = HtmlRenderer.RenderIndexPage([], new DebugProbeOptions { TrendLookbackMinutes = 30 });

// Flat sparkline check: Y values should all be 14
Assert.Contains("14", html);
Assert.Contains("trend-neutral", html);
Assert.Contains("→", html);
}

[Fact]
public void Render_index_page_with_increased_errors_shows_trend_up()
{
var now = DateTimeOffset.UtcNow;
var options = new DebugProbeOptions { TrendLookbackMinutes = 10 };

var items = new List<DebugEntry>
{
// Preceding half: [now - 10m, now - 5m). 1 request, 0 errors -> 0% error rate.
new() { Id = "1", Timestamp = now.AddMinutes(-7), StatusCode = 200, Method = "GET", Path = "/api" },
// Current half: [now - 5m, now]. 1 request, 1 error -> 100% error rate.
new() { Id = "2", Timestamp = now.AddMinutes(-2), StatusCode = 500, Method = "GET", Path = "/api" }
};

var html = HtmlRenderer.RenderIndexPage(items, options);

Assert.Contains("trend-up", html);
Assert.Contains("↑", html);
}

[Fact]
public void Render_index_page_with_decreased_errors_shows_trend_down()
{
var now = DateTimeOffset.UtcNow;
var options = new DebugProbeOptions { TrendLookbackMinutes = 10 };

var items = new List<DebugEntry>
{
// Preceding half: [now - 10m, now - 5m). 1 request, 1 error -> 100% error rate.
new() { Id = "1", Timestamp = now.AddMinutes(-7), StatusCode = 500, Method = "GET", Path = "/api" },
// Current half: [now - 5m, now]. 1 request, 0 errors -> 0% error rate.
new() { Id = "2", Timestamp = now.AddMinutes(-2), StatusCode = 200, Method = "GET", Path = "/api" }
};

var html = HtmlRenderer.RenderIndexPage(items, options);

Assert.Contains("trend-down", html);
Assert.Contains("↓", html);
}

[Fact]
public void Render_index_page_with_unchanged_errors_shows_trend_neutral()
{
var now = DateTimeOffset.UtcNow;
var options = new DebugProbeOptions { TrendLookbackMinutes = 10 };

var items = new List<DebugEntry>
{
// Preceding half: [now - 10m, now - 5m). 1 request, 0 errors -> 0% error rate.
new() { Id = "1", Timestamp = now.AddMinutes(-7), StatusCode = 200, Method = "GET", Path = "/api" },
// Current half: [now - 5m, now]. 1 request, 0 errors -> 0% error rate.
new() { Id = "2", Timestamp = now.AddMinutes(-2), StatusCode = 200, Method = "GET", Path = "/api" }
};

var html = HtmlRenderer.RenderIndexPage(items, options);

Assert.Contains("trend-neutral", html);
Assert.Contains("→", html);
}

[Fact]
public void Render_index_page_scenario_a_and_b_regression_test()
{
var options = new DebugProbeOptions { MaxEntries = 10, TrendLookbackMinutes = 2 };

var now = DateTimeOffset.UtcNow;

// Scenario A: 8 successes at t = now - 90s, then 5 errors at t = now - 5s
// Since MaxEntries = 10, 3 successes are evicted. 5 successes remain in previous window, 5 errors in current.
// TotalB = 5 > 0, TotalA = 5 > 0.
var itemsA = new List<DebugEntry>();
for (int i = 0; i < 5; i++)
{
itemsA.Add(new DebugEntry { Id = $"success-{i}", Timestamp = now.AddSeconds(-90), StatusCode = 200, Method = "GET", Path = "/api" });
}
for (int i = 0; i < 5; i++)
{
itemsA.Add(new DebugEntry { Id = $"error-{i}", Timestamp = now.AddSeconds(-5), StatusCode = 500, Method = "GET", Path = "/api" });
}

// Expected: should show Trend Up (↑) because previous window is valid and error rate went up from 0% to 100%
var htmlA = HtmlRenderer.RenderIndexPage(itemsA, options);
Assert.Contains("trend-up", htmlA);
Assert.Contains("↑", htmlA);

// Scenario B: 8 recovery successes are sent, evicting all baseline successes and some errors.
// Queue now has 8 recovery successes and 2 errors (all in current window).
// Previous window is empty (TotalB = 0).
var itemsB = new List<DebugEntry>();
for (int i = 0; i < 2; i++)
{
itemsB.Add(new DebugEntry { Id = $"error-{i}", Timestamp = now.AddSeconds(-5), StatusCode = 500, Method = "GET", Path = "/api" });
}
for (int i = 0; i < 8; i++)
{
itemsB.Add(new DebugEntry { Id = $"recovery-{i}", Timestamp = now.AddSeconds(-2), StatusCode = 200, Method = "GET", Path = "/api" });
}

// Expected: should show Trend Neutral (→) because previous window is empty
var htmlB = HtmlRenderer.RenderIndexPage(itemsB, options);
Assert.Contains("trend-neutral", htmlB);
Assert.Contains("→", htmlB);
}

[Fact]
public void Render_index_page_previous_window_evicted_shows_neutral()
{
var options = new DebugProbeOptions { MaxEntries = 10, TrendLookbackMinutes = 2 };
var items = new List<DebugEntry>();

var now = DateTimeOffset.UtcNow;
// All baseline entries from previous window are evicted. Previous window has 0 entries.
for (int i = 0; i < 10; i++)
{
items.Add(new DebugEntry { Id = $"recovery-{i}", Timestamp = now.AddSeconds(-2), StatusCode = 200, Method = "GET", Path = "/api" });
}

var html = HtmlRenderer.RenderIndexPage(items, options);
Assert.Contains("trend-neutral", html);
Assert.Contains("→", html);
}

[Fact]
public void Render_index_page_previous_window_no_traffic_shows_neutral()
{
var options = new DebugProbeOptions { MaxEntries = 100, TrendLookbackMinutes = 2 };
var items = new List<DebugEntry>();

var now = DateTimeOffset.UtcNow;
// Only 5 errors are sent now. No traffic occurred in the previous window.
for (int i = 0; i < 5; i++)
{
items.Add(new DebugEntry { Id = $"error-{i}", Timestamp = now.AddSeconds(-5), StatusCode = 500, Method = "GET", Path = "/api" });
}

var html = HtmlRenderer.RenderIndexPage(items, options);
Assert.Contains("trend-neutral", html);
Assert.Contains("→", html);
}

[Fact]
public void Render_index_page_happy_path_with_no_eviction_flips_trend()
{
var options = new DebugProbeOptions { MaxEntries = 100, TrendLookbackMinutes = 2 };
var store = new DebugEntryStore(options);

var now = DateTimeOffset.UtcNow;
// Phase 1: 8 successes at t = now - 90s (previous window)
for (int i = 0; i < 8; i++)
{
store.Add(new DebugEntry { Id = $"success-{i}", Timestamp = now.AddSeconds(-90), StatusCode = 200, Method = "GET", Path = "/api" });
}

// Phase 2: 5 errors at t = now - 5s (current window)
for (int i = 0; i < 5; i++)
{
store.Add(new DebugEntry { Id = $"error-{i}", Timestamp = now.AddSeconds(-5), StatusCode = 500, Method = "GET", Path = "/api" });
}

var htmlErrors = HtmlRenderer.RenderIndexPage(store.GetAll(), options);
Assert.Contains("trend-up", htmlErrors);
Assert.Contains("↑", htmlErrors);

store.Clear();

// Phase 3: 5 errors at t = now - 90s (previous window)
for (int i = 0; i < 5; i++)
{
store.Add(new DebugEntry { Id = $"error-{i}", Timestamp = now.AddSeconds(-90), StatusCode = 500, Method = "GET", Path = "/api" });
}

// Phase 4: 8 successes at t = now - 5s (current window)
for (int i = 0; i < 8; i++)
{
store.Add(new DebugEntry { Id = $"success-{i}", Timestamp = now.AddSeconds(-5), StatusCode = 200, Method = "GET", Path = "/api" });
}

var htmlRecovery = HtmlRenderer.RenderIndexPage(store.GetAll(), options);
Assert.Contains("trend-down", htmlRecovery);
Assert.Contains("↓", htmlRecovery);
}

[Fact]
public void Options_validator_rejects_trend_lookback_less_than_two()
{
var validator = new DebugProbeOptionsValidator();
var options = new DebugProbeOptions { TrendLookbackMinutes = 1 };

var result = validator.Validate(null, options);

Assert.True(result.Failed);
Assert.Contains("TrendLookbackMinutes must be greater than or equal to 2", result.FailureMessage);
}
}
23 changes: 22 additions & 1 deletion DebugProbe.AspNetCore/Assets/css/debugprobe.css
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ h4 {

.stats-bar {
display: grid;
grid-template-columns: repeat(4, minmax(150px, 1fr));
grid-template-columns: repeat(5, minmax(150px, 1fr));
gap: 10px;
margin-bottom: 14px;
}
Expand Down Expand Up @@ -108,6 +108,27 @@ h4 {
text-transform: uppercase;
}

.trend-arrow {
display: inline-block;
font-size: 16px;
margin-left: 4px;
vertical-align: middle;
line-height: 1;
}

.trend-up {
color: #e74c3c;
}

.trend-down {
color: #27ae60;
}

.trend-neutral {
color: #9ca3af;
}


.filters input,
.filters select {
min-height: 36px;
Expand Down
8 changes: 7 additions & 1 deletion DebugProbe.AspNetCore/Assets/html/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,15 @@ <h2>Requests</h2>
<span>Over 1s</span>
</div>
<div class="stat-tile">
<strong>{{error_rate}}</strong>
<strong>{{error_rate}}{{error_trend}}</strong>
<span>Errors</span>
</div>
<div class="stat-tile">
<div style="display: flex; align-items: center; justify-content: center; height: 28px; width: 120px;">
{{sparkline}}
</div>
<span>Activity</span>
</div>
</div>

<div class="filters" aria-label="Request filters">
Expand Down
89 changes: 87 additions & 2 deletions DebugProbe.AspNetCore/Internal/Rendering/HtmlRenderer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -64,8 +64,91 @@ public static string RenderIndexPage(List<DebugEntry> items, DebugProbeOptions?
var slowRequests = slowRequestThresholdMs > 0 ? items.Count(x => x.DurationMs >= slowRequestThresholdMs) : 0;
var errorRate = totalRequests == 0 ? 0 : items.Count(x => x.StatusCode >= 400) * 100d / totalRequests;

var exceptionPanel = "";
// Trend calculations
var store = DebugEntryStore.Instance;
var now = DateTimeOffset.UtcNow;
var limitTime = now.AddMinutes(-options.TrendLookbackMinutes);
var midTime = now.AddMinutes(-options.TrendLookbackMinutes / 2.0);

int[] buckets = new int[options.TrendLookbackMinutes];
int totalA = 0;
int errorsA = 0;
int totalB = 0;
int errorsB = 0;

foreach (var entry in items)
{
var t = entry.Timestamp;
var elapsed = now - t;
if (elapsed.TotalMinutes < options.TrendLookbackMinutes)
{
double mins = Math.Max(0.0, elapsed.TotalMinutes);
int bucketIndex = options.TrendLookbackMinutes - 1 - (int)Math.Floor(mins);
if (bucketIndex >= 0 && bucketIndex < options.TrendLookbackMinutes)
{
buckets[bucketIndex]++;
}
}

if (t >= midTime)
{
totalA++;
if (entry.StatusCode >= 400)
{
errorsA++;
}
}
else if (t >= limitTime && t < midTime)
{
totalB++;
if (entry.StatusCode >= 400)
{
errorsB++;
}
}
}

int maxVal = buckets.Max();
var pointsList = new List<string>();
for (int i = 0; i < options.TrendLookbackMinutes; i++)
{
double x = (double)i / (options.TrendLookbackMinutes - 1) * 120.0;
double y = maxVal == 0 ? 14.0 : 26.0 - ((double)buckets[i] / maxVal * 24.0);
pointsList.Add($"{x:0.##},{y:0.##}");
}
string pointsString = string.Join(" ", pointsList);

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>";

double errorRateA = totalA == 0 ? 0.0 : (double)errorsA / totalA;
double errorRateB = totalB == 0 ? 0.0 : (double)errorsB / totalB;

string trendArrow;
string arrowClass;
if (totalA == 0 || totalB == 0)
{
trendArrow = "→";
arrowClass = "trend-neutral";
}
else if (errorRateA > errorRateB)
{
trendArrow = "↑";
arrowClass = "trend-up";
}
else if (errorRateA < errorRateB)
{
trendArrow = "↓";
arrowClass = "trend-down";
}
else
{
trendArrow = "→";
arrowClass = "trend-neutral";
}

string errorTrendHtml = $" <span class=\"trend-arrow {arrowClass}\" title=\"vs preceding period\">{trendArrow}</span>";

var exceptionPanel = "";
if (store != null && !store.ExceptionGroups.IsEmpty)
{
var sortedGroups = store.ExceptionGroups.Values
Expand Down Expand Up @@ -116,7 +199,9 @@ public static string RenderIndexPage(List<DebugEntry> items, DebugProbeOptions?
.Replace("{{total_requests}}", FormatCompactNumber(totalRequests))
.Replace("{{avg_response_time}}", $"{averageResponseMs} ms")
.Replace("{{slow_requests}}", FormatCompactNumber(slowRequests))
.Replace("{{error_rate}}", $"{errorRate:0.#}%"));
.Replace("{{error_rate}}", $"{errorRate:0.#}%")
.Replace("{{error_trend}}", errorTrendHtml)
.Replace("{{sparkline}}", sparklineSvg));
}

public static string RenderDetailsPage(DebugEntry x, DebugEnvironment e, string req, string res, DebugProbeOptions? options = null)
Expand Down
6 changes: 6 additions & 0 deletions DebugProbe.AspNetCore/Options/DebugProbeOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,12 @@ public int MaxBodyCaptureSizeKb
/// </summary>
public int SlowRequestThresholdMs { get; set; } = 1000;

/// <summary>
/// Lookback window in minutes for the request rate sparkline and error rate trend.
/// Defaults to 30. Must be greater than or equal to 2.
/// </summary>
public int TrendLookbackMinutes { get; set; } = 2;

/// <summary>
/// Allows compare operations to target localhost and private network addresses.
/// Defaults to true in Development and false in other environments unless explicitly configured.
Expand Down
Loading
Loading