From a44e37a7c2f05e04f440cb46ba4c5eca33c5be10 Mon Sep 17 00:00:00 2001 From: Oliver Kieselbach Date: Fri, 24 Jul 2026 16:25:11 +0200 Subject: [PATCH] fix(metrics): keep full failure-reason text for fleet-health display BuildFleetFailureReasons truncated each reason to a 50-char prefix + "..." and used that as both the grouping key AND the display value. The full text never reached the UI, so its tooltip/tap-to-expand had nothing to reveal and near-identical rows were indistinguishable past the cut. Group by the 50-char prefix as before (variants still collapse into one row), but carry the longest full reason as the display value. The UI truncates and expands it on demand, so the complete text is now available end-to-end. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../FleetHealthPayloadTests.cs | 33 ++++++++++--------- .../Helpers/MetricsMath.cs | 24 ++++++++++---- 2 files changed, 34 insertions(+), 23 deletions(-) diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/FleetHealthPayloadTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/FleetHealthPayloadTests.cs index b7498ee8..dc438688 100644 --- a/src/Backend/AutopilotMonitor.Functions.Tests/FleetHealthPayloadTests.cs +++ b/src/Backend/AutopilotMonitor.Functions.Tests/FleetHealthPayloadTests.cs @@ -213,30 +213,31 @@ public class FleetHealthPayloadTests } [Fact] - public void FailureReasons_TruncateLongReasons_AndTakeTopFive() + public void FailureReasons_GroupByPrefix_KeepFullestText_AndTakeTopFive() { var sessions = new List(); - // 6 distinct reasons with descending frequency 6..1 → only top 5 kept. - for (int freq = 6; freq >= 1; freq--) + // 5 distinct short reasons with descending frequency 5..1. + for (int freq = 5; freq >= 1; freq--) for (int i = 0; i < freq; i++) sessions.Add(S(SessionStatus.Failed, failureReason: $"Reason {freq}")); - // A null reason folds into "Unknown". - sessions.Add(S(SessionStatus.Failed, failureReason: null)); - // An over-long reason is collapsed to a 50-char prefix + ellipsis. - var longReason = new string('x', 60); - sessions.Add(S(SessionStatus.Failed, failureReason: longReason)); + + // Two variants that share the first 50 characters collapse into one group; + // the longest variant is kept for display so the UI can show the full text. + var prefix = new string('x', 50); + var shortVariant = prefix + " short"; + var longVariant = prefix + " a much longer tail that must survive intact"; + for (int i = 0; i < 6; i++) + sessions.Add(S(SessionStatus.Failed, failureReason: shortVariant)); + sessions.Add(S(SessionStatus.Failed, failureReason: longVariant)); var payload = MetricsMath.BuildFleetHealthPayload(sessions, days: 30); Assert.Equal(5, payload.FailureReasons.Count); - Assert.Equal("Reason 6", payload.FailureReasons[0].Reason); - Assert.Equal(6, payload.FailureReasons[0].Count); - // The collapsed long reason exists in the tally (50 chars + "..."). - var collapsed = new string('x', 50) + "..."; - Assert.True( - sessions.Count(s => s.FailureReason == longReason) == 1, - "sanity: exactly one long-reason session"); - Assert.Equal(53, collapsed.Length); + // The grouped cluster (6 + 1 = 7) outranks every "Reason N". + Assert.Equal(7, payload.FailureReasons[0].Count); + // The full, longest text survives end-to-end — no 50-char truncation. + Assert.Equal(longVariant, payload.FailureReasons[0].Reason); + Assert.DoesNotContain("...", payload.FailureReasons[0].Reason); } [Fact] diff --git a/src/Backend/AutopilotMonitor.Functions/Helpers/MetricsMath.cs b/src/Backend/AutopilotMonitor.Functions/Helpers/MetricsMath.cs index beefc334..94a55419 100644 --- a/src/Backend/AutopilotMonitor.Functions/Helpers/MetricsMath.cs +++ b/src/Backend/AutopilotMonitor.Functions/Helpers/MetricsMath.cs @@ -194,19 +194,29 @@ public static class MetricsMath private static List BuildFleetFailureReasons(IReadOnlyList sessions) { - var reasons = new Dictionary(); + // Group near-identical messages by a 50-char prefix so variants collapse + // into one row, but keep the longest full reason as the display value. The + // UI truncates and expands it on demand, so the complete text must survive. + var groups = new Dictionary(); foreach (var s in sessions) { if (s.Status != SessionStatus.Failed) continue; var reason = string.IsNullOrEmpty(s.FailureReason) ? "Unknown" : s.FailureReason; - // Collapse very long reasons to a 50-char prefix so near-identical messages group. - if (reason.Length > 50) reason = reason.Substring(0, 50) + "..."; - reasons[reason] = reasons.GetValueOrDefault(reason) + 1; + var key = reason.Length > 50 ? reason.Substring(0, 50) : reason; + if (groups.TryGetValue(key, out var g)) + { + var display = reason.Length > g.Display.Length ? reason : g.Display; + groups[key] = (display, g.Count + 1); + } + else + { + groups[key] = (reason, 1); + } } - return reasons - .OrderByDescending(kv => kv.Value) + return groups + .OrderByDescending(kv => kv.Value.Count) .Take(5) - .Select(kv => new FleetFailureReason { Reason = kv.Key, Count = kv.Value }) + .Select(kv => new FleetFailureReason { Reason = kv.Value.Display, Count = kv.Value.Count }) .ToList(); }