diff --git a/.server-changes/report-start-latency-unknown.md b/.server-changes/report-start-latency-unknown.md new file mode 100644 index 0000000000..2aa06d99d7 --- /dev/null +++ b/.server-changes/report-start-latency-unknown.md @@ -0,0 +1,6 @@ +--- +area: webapp +type: fix +--- + +The health report now says start latency is "unknown" when there is no data for it, instead of showing a healthy-looking 0ms diff --git a/apps/webapp/app/presenters/v3/reports/health/health-core.ts b/apps/webapp/app/presenters/v3/reports/health/health-core.ts index 78fb1d9fbe..0d14bb2b59 100644 --- a/apps/webapp/app/presenters/v3/reports/health/health-core.ts +++ b/apps/webapp/app/presenters/v3/reports/health/health-core.ts @@ -21,7 +21,16 @@ export type HealthInput = { * series measured (v2) or estimated (v1). */ pending: { now: number; normal?: number; series: number[]; estimated: boolean }; - startLatency: { p95Ms: number; normalP95Ms: number; series: number[] }; + /** + * p95 wait. `availability: "unknown"` = the source had no measurement, so `p95Ms` is a + * placeholder that must not be graded — a 0 would read as a confident green. + */ + startLatency: { + p95Ms: number; + normalP95Ms?: number; + series: number[]; + availability?: "measured" | "unknown"; + }; throughput: { donePerMin: number; triggeredPerMin: number; normalTriggeredPerMin: number }; failures: { rate: number; normalRate: number; series: number[] }; duration: { p95Ms: number; normalP95Ms: number }; @@ -116,21 +125,30 @@ export function buildMetrics(input: HealthInput): Metric[] { const t = HEALTH_THRESHOLDS; const ev = input.flowEvidence; + const startLatencyUnknown = input.startLatency.availability === "unknown"; const startLatency: Metric = { id: "start_latency_p95", value: input.startLatency.p95Ms, + availability: startLatencyUnknown ? "unknown" : "measured", unit: "ms", aggregation: "p95", - normal: input.startLatency.normalP95Ms, - delta: delta(input.startLatency.p95Ms, input.startLatency.normalP95Ms), - series: { points: input.startLatency.series, kind: "measured" }, - severity: multiplierSeverity( - input.startLatency.p95Ms, - input.startLatency.normalP95Ms, - t.startLatency.warnMult, - t.startLatency.critMult, - t.startLatency.floor - ), + normal: startLatencyUnknown ? undefined : input.startLatency.normalP95Ms, + delta: startLatencyUnknown + ? undefined + : delta(input.startLatency.p95Ms, input.startLatency.normalP95Ms), + series: startLatencyUnknown + ? undefined + : { points: input.startLatency.series, kind: "measured" }, + // Nothing measured -> nothing to classify (a placeholder must never grade green). + severity: startLatencyUnknown + ? "ok" + : multiplierSeverity( + input.startLatency.p95Ms, + input.startLatency.normalP95Ms, + t.startLatency.warnMult, + t.startLatency.critMult, + t.startLatency.floor + ), }; const pending: Metric = { diff --git a/apps/webapp/app/presenters/v3/reports/health/health-data.ts b/apps/webapp/app/presenters/v3/reports/health/health-data.ts index c1e9639d92..6bc79d98cd 100644 --- a/apps/webapp/app/presenters/v3/reports/health/health-data.ts +++ b/apps/webapp/app/presenters/v3/reports/health/health-data.ts @@ -39,6 +39,12 @@ function num(value: unknown, fallback = 0): number { return Number.isFinite(n) ? n : fallback; } +/** Like `num`, but keeps "no measurement" distinct from a measured 0. */ +function optionalNum(value: unknown): number | undefined { + const n = num(value, NaN); + return Number.isFinite(n) ? n : undefined; +} + function mean(xs: number[]): number { return xs.length === 0 ? 0 : xs.reduce((a, b) => a + b, 0) / xs.length; } @@ -313,7 +319,7 @@ async function tryQuery( export type FlowData = { flowSource: HealthInput["flowSource"]; pending: { now: number; normal?: number; series: number[]; estimated: boolean }; - startLatency: { p95Ms: number; normalP95Ms: number; series: number[] }; + startLatency: HealthInput["startLatency"]; evidence: HealthInput["flowEvidence"]; /** * Epoch ms of the freshest telemetry the source saw (latest env_metrics bucket and/or latest @@ -432,6 +438,8 @@ function buildQueueMetricsFlow( // env_metrics (still a real number) rather than a misleading confident zero (#7). const lastMeasuredQueued = num(series[series.length - 1]?.queued); + const waitP95 = optionalNum(liveScalar.wait_p95); + return { flowSource: "queue_metrics_v1", pending: { @@ -441,9 +449,10 @@ function buildQueueMetricsFlow( estimated: false, // measured }, startLatency: { - p95Ms: num(liveScalar.wait_p95), - normalP95Ms: num(baselineScalar.wait_p95), + p95Ms: waitP95 ?? 0, + normalP95Ms: optionalNum(baselineScalar.wait_p95), series: resampleSeries(series.map((r) => num(r.wait_p95))), + availability: waitP95 === undefined ? "unknown" : "measured", }, evidence: { // native resolution — cause discriminators read shares off this series. @@ -476,6 +485,7 @@ export const SnapshotFlowSource: FlowSource = { return backlog; }); const series = resampleSeries(proxy); + const startLatencyP95 = optionalNum(ctx.liveScalar.start_latency_p95); return { flowSource: "snapshot+runs", @@ -488,9 +498,10 @@ export const SnapshotFlowSource: FlowSource = { estimated: true, }, startLatency: { - p95Ms: num(ctx.liveScalar.start_latency_p95), - normalP95Ms: num(ctx.baselineScalar.start_latency_p95), + p95Ms: startLatencyP95 ?? 0, + normalP95Ms: optionalNum(ctx.baselineScalar.start_latency_p95), series: resampleSeries(ctx.liveSeries.map((r) => num(r.start_latency_p95))), + availability: startLatencyP95 === undefined ? "unknown" : "measured", }, // No cause-tree evidence; interpret falls back to v1 symptoms. evidence: EMPTY_EVIDENCE, diff --git a/apps/webapp/app/presenters/v3/reports/renderMarkdown.ts b/apps/webapp/app/presenters/v3/reports/renderMarkdown.ts index 0eb82ab47f..35f9b74176 100644 --- a/apps/webapp/app/presenters/v3/reports/renderMarkdown.ts +++ b/apps/webapp/app/presenters/v3/reports/renderMarkdown.ts @@ -178,6 +178,8 @@ function annotationSegment(metric: Metric, vm: ReportViewModel): string { } function metricValueText(metric: Metric, msg: ReportMessages): string { + // No measurement -> say so; `value` is a placeholder, not a real reading. + if (metric.availability === "unknown") return "unknown"; // concurrency etc. carry a limit -> "running/limit". if (metric.unit === "count" && metric.breakdown?.limit !== undefined) { return `${fmtCount(metric.value)}/${fmtCount(metric.breakdown.limit)}`; @@ -259,7 +261,9 @@ function compactFact(metric: Metric): string | undefined { case "pending": return `pending ${fmtCount(metric.value)}${metric.normal !== undefined ? ` (normal ~${fmtCount(metric.normal)})` : ""}`; case "start_latency_p95": - return `starts p95 ${fmtDuration(metric.value)}`; + return metric.availability === "unknown" + ? "starts p95 unknown" + : `starts p95 ${fmtDuration(metric.value)}`; case "failures": return `failures ${fmtPct(metric.value)}${metric.normal !== undefined ? ` (normal ~${fmtPct(metric.normal)})` : ""}`; case "dur_p95": diff --git a/apps/webapp/test/reportHealth.test.ts b/apps/webapp/test/reportHealth.test.ts index 22f940ffbd..40b054f07e 100644 --- a/apps/webapp/test/reportHealth.test.ts +++ b/apps/webapp/test/reportHealth.test.ts @@ -495,6 +495,37 @@ describe("freshness unknown is distinct from lagging", () => { }); }); +describe("start latency with no measurement", () => { + const unknownInput: HealthInput = { + ...INPUT_B, + startLatency: { p95Ms: 0, normalP95Ms: undefined, series: [], availability: "unknown" }, + }; + + it("marks the metric 'unknown' and doesn't classify it", () => { + const metric = interpret(unknownInput).metrics.find((m) => m.id === "start_latency_p95")!; + expect(metric.availability).toBe("unknown"); + expect(metric.severity).toBe("ok"); + expect(metric.normal).toBeUndefined(); + expect(metric.series).toBeUndefined(); // no sparkline for a placeholder + }); + + it("renders 'unknown', never a confident 0ms", () => { + const md = renderReportMarkdown(interpret(unknownInput)); + expect(md).toContain("starts p95 unknown"); + expect(md).not.toContain("starts p95 0ms"); + }); + + it("keeps a genuine 0 a measured 0ms", () => { + const measured = interpret({ + ...INPUT_B, + startLatency: { p95Ms: 0, normalP95Ms: 7000, series: [0, 0], availability: "measured" }, + }); + const metric = measured.metrics.find((m) => m.id === "start_latency_p95")!; + expect(metric.availability).toBe("measured"); + expect(renderReportMarkdown(measured)).toContain("starts p95 0ms"); + }); +}); + describe("zero baseline is not a false green (absolute floors)", () => { it("pending spiking from a 0 baseline is not healthy", () => { const vm = interpret({ diff --git a/apps/webapp/test/reportHealthData.test.ts b/apps/webapp/test/reportHealthData.test.ts index ae29f7a370..44e81d07ce 100644 --- a/apps/webapp/test/reportHealthData.test.ts +++ b/apps/webapp/test/reportHealthData.test.ts @@ -239,4 +239,50 @@ describe("loadHealthInput — orchestration (query seam)", () => { expect(input.flowSource).toBe("queue_metrics_v1"); expect(input.pending.now).toBe(900); }); + + it("no wait_p95 measurement -> start latency 'unknown', not a confident 0", async () => { + const input = await loadHealthInput( + fakeEnv, + "1h", + NOW, + makeDeps({ + runs: RUNS_SCALAR, + envSeries: [{ t: "a", queued: 10, running: 5, throttled: 0 }], + envScalar: [{ wait_p95: null, avg_queued: 8, env_limit: 100 }], + }) + ); + expect(input.flowSource).toBe("queue_metrics_v1"); + expect(input.startLatency.availability).toBe("unknown"); + expect(input.startLatency.normalP95Ms).toBeUndefined(); + }); + + it("a measured wait_p95 of 0 stays measured", async () => { + const input = await loadHealthInput( + fakeEnv, + "1h", + NOW, + makeDeps({ + runs: RUNS_SCALAR, + envSeries: [{ t: "a", queued: 10, running: 5, throttled: 0, wait_p95: 0 }], + envScalar: [{ wait_p95: 0, avg_queued: 8, env_limit: 100 }], + }) + ); + expect(input.startLatency.availability).toBe("measured"); + expect(input.startLatency.p95Ms).toBe(0); + }); + + it("snapshot path with no start_latency_p95 -> 'unknown'", async () => { + const input = await loadHealthInput( + fakeEnv, + "1h", + NOW, + makeDeps({ + runs: [{ ...RUNS_SCALAR[0], start_latency_p95: null }], + runsSeries: [{ t: "a", triggered: 10, completed: 8, failures: 0 }], + envSeries: [], + }) + ); + expect(input.flowSource).toBe("snapshot+runs"); + expect(input.startLatency.availability).toBe("unknown"); + }); });