From 786212643c01255f5b79b7ff658b7414e0454b77 Mon Sep 17 00:00:00 2001 From: Brandon Pereira Date: Thu, 12 Nov 2026 12:09:33 -0600 Subject: [PATCH 1/2] fix(mcp): drop the health verdict from clickstack_service_health The critical/degraded/healthy status and "no action needed" summaries judged services from service-wide totals, which can miss a regression in one endpoint, pod or segment and stop the agent investigating. Report the threshold flags, score and raw values instead. HDX-5877 --- .changeset/service-health-no-verdict.md | 11 +++ .../__tests__/serviceHealthTool.int.test.ts | 19 ++--- .../query/__tests__/serviceHealthRank.test.ts | 74 +++++++++++++++---- .../mcp/tools/query/serviceHealth/index.ts | 17 ++--- .../src/mcp/tools/query/serviceHealth/rank.ts | 31 +++----- .../mcp/tools/query/serviceHealth/report.ts | 51 ++++++------- .../mcp/tools/query/serviceHealth/summary.ts | 23 +++--- 7 files changed, 129 insertions(+), 97 deletions(-) create mode 100644 .changeset/service-health-no-verdict.md diff --git a/.changeset/service-health-no-verdict.md b/.changeset/service-health-no-verdict.md new file mode 100644 index 0000000000..855697d73f --- /dev/null +++ b/.changeset/service-health-no-verdict.md @@ -0,0 +1,11 @@ +--- +'@hyperdx/api': patch +--- + +fix(mcp): drop the health verdict from clickstack_service_health + +The tool no longer labels services critical, degraded or healthy, and no longer +tells agents a service needs "no action". Each service keeps its threshold +flags, score and a factual summary, so agents reason from the numbers instead of +a verdict that service-wide totals can get wrong. `summary.statusCounts` is +replaced by `summary.servicesFlagged`. diff --git a/packages/api/src/mcp/__tests__/serviceHealthTool.int.test.ts b/packages/api/src/mcp/__tests__/serviceHealthTool.int.test.ts index be604d69eb..271d7711f6 100644 --- a/packages/api/src/mcp/__tests__/serviceHealthTool.int.test.ts +++ b/packages/api/src/mcp/__tests__/serviceHealthTool.int.test.ts @@ -32,7 +32,6 @@ type Span = { type ServiceReport = { service: string; - status: string; flags: string[]; current: { requests: number; @@ -218,23 +217,21 @@ describe('MCP Service Health Tool', () => { ]); }); - it('ranks regressions first and leaves steady services healthy', async () => { + it('ranks regressions first and leaves steady services unflagged', async () => { const { summary, services } = parse(await call()); - expect(services.map(s => [s.service, s.status])).toEqual([ - [svc('erroring'), 'critical'], - [svc('slow'), 'degraded'], - [svc('steady'), 'healthy'], + expect(services.map(s => s.service)).toEqual([ + svc('erroring'), + svc('slow'), + svc('steady'), ]); + expect(services[0]).not.toHaveProperty('status'); + expect(services[2].flags).toEqual([]); expect(services[0].flags).toEqual( expect.arrayContaining(['error_rate_up', 'high_error_rate']), ); expect(services[1].flags).toContain('latency_up'); expect(services[1].current.p95Ms).toBeCloseTo(600, -1); - expect(summary.statusCounts).toEqual({ - critical: 1, - degraded: 1, - healthy: 1, - }); + expect(summary.servicesFlagged).toBe(2); }); it('counts server spans only by default', async () => { diff --git a/packages/api/src/mcp/tools/query/__tests__/serviceHealthRank.test.ts b/packages/api/src/mcp/tools/query/__tests__/serviceHealthRank.test.ts index a0a2140c54..f7138d3b8f 100644 --- a/packages/api/src/mcp/tools/query/__tests__/serviceHealthRank.test.ts +++ b/packages/api/src/mcp/tools/query/__tests__/serviceHealthRank.test.ts @@ -2,6 +2,7 @@ import { errorGroupKey } from '@/mcp/tools/query/serviceHealth/errorGroups'; import type { NewOperation } from '@/mcp/tools/query/serviceHealth/operations'; import { assessService, + crossedThreshold, HEALTH_THRESHOLDS, type WindowStats, } from '@/mcp/tools/query/serviceHealth/rank'; @@ -28,9 +29,9 @@ const stats = (s: Partial = {}): WindowStats => ({ }); describe('assessService', () => { - it('is healthy when nothing moved', () => { + it('flags nothing when nothing moved', () => { const a = assessService(stats(), stats(), 50); - expect(a.status).toBe('healthy'); + expect(crossedThreshold(a.flags)).toBe(false); expect(a.flags).toEqual([]); }); @@ -38,29 +39,29 @@ describe('assessService', () => { // 0.5% -> 1.0%: 2x, but only +0.5pp const a = assessService(stats({ errors: 10 }), stats({ errors: 5 }), 50); expect(a.flags).not.toContain('error_rate_up'); - expect(a.status).toBe('healthy'); + expect(crossedThreshold(a.flags)).toBe(false); }); it('flags error_rate_up at exactly the ratio and delta thresholds', () => { // 1% -> 2%: exactly 2x and +1pp const a = assessService(stats({ errors: 20 }), stats({ errors: 10 }), 50); expect(a.flags).toContain('error_rate_up'); - expect(a.status).toBe('degraded'); + expect(crossedThreshold(a.flags)).toBe(true); expect(a.deltas.errorRateDeltaPp).toBe(1); }); - it('is critical when errors rose past the high error rate', () => { + it('flags both error rules when errors rose past the high error rate', () => { const a = assessService(stats({ errors: 80 }), stats({ errors: 5 }), 50); expect(a.flags).toEqual( expect.arrayContaining(['error_rate_up', 'high_error_rate']), ); - expect(a.status).toBe('critical'); + expect(crossedThreshold(a.flags)).toBe(true); }); - it('marks a chronically high error rate degraded, not critical', () => { + it('flags a chronically high error rate without error_rate_up', () => { const a = assessService(stats({ errors: 80 }), stats({ errors: 80 }), 50); expect(a.flags).toEqual(['high_error_rate']); - expect(a.status).toBe('degraded'); + expect(crossedThreshold(a.flags)).toBe(true); }); it('needs both the ratio and the ms delta for latency_up', () => { @@ -79,27 +80,27 @@ describe('assessService', () => { it('flags traffic drops and spikes', () => { const drop = assessService(stats({ requests: 500 }), stats(), 50); expect(drop.flags).toContain('traffic_drop'); - expect(drop.status).toBe('degraded'); + expect(crossedThreshold(drop.flags)).toBe(true); const spike = assessService(stats({ requests: 3000 }), stats(), 50); expect(spike.flags).toContain('traffic_spike'); - expect(spike.status).toBe('healthy'); + expect(crossedThreshold(spike.flags)).toBe(false); }); it('flags new and missing services', () => { const fresh = assessService(stats(), null, 50); expect(fresh.flags).toEqual(['new_service']); - expect(fresh.status).toBe('healthy'); + expect(crossedThreshold(fresh.flags)).toBe(false); const gone = assessService(null, stats(), 50); expect(gone.flags).toEqual(['missing_service']); - expect(gone.status).toBe('degraded'); + expect(crossedThreshold(gone.flags)).toBe(true); }); it('treats a handful of prior requests as no baseline', () => { const a = assessService(stats(), stats({ requests: 1, errors: 1 }), 50); expect(a.flags).toEqual(['new_service']); - expect(a.status).toBe('healthy'); + expect(crossedThreshold(a.flags)).toBe(false); }); it('never flags services under minRequests in both windows', () => { @@ -110,7 +111,7 @@ describe('assessService', () => { ); expect(a.flags).toEqual(['low_volume']); expect(a.score).toBe(0); - expect(a.status).toBe('healthy'); + expect(crossedThreshold(a.flags)).toBe(false); }); it('applies absolute rules only when comparisons are off', () => { @@ -126,7 +127,7 @@ describe('assessService', () => { }); describe('rankServices', () => { - it('sorts by status, then by score with volume as a multiplier', () => { + it('sorts flagged services first, then by score with volume as a multiplier', () => { const cur = new Map([ ['steady', stats()], ['small-regression', stats({ requests: 100, errors: 10 })], @@ -326,6 +327,49 @@ describe('buildReport', () => { expect(svc.topErrors[0].status).toBe('new_operation'); }); + it('reports facts, not a health verdict', () => { + const steady = reportFor(stats(), stats()); + expect(steady).not.toHaveProperty('status'); + expect(steady.summary).toMatch( + /^No metric crossed a threshold vs previous_period: error rate 0\.50% vs 0\.50%, p95 200ms vs 200ms, requests 1000 vs 1000\./, + ); + const regressed = reportFor(stats({ errors: 80 }), stats()); + expect(regressed.summary).toMatch( + /^Crossed thresholds: error rate 8\.00% vs 0\.50%\./, + ); + for (const s of [steady.summary, regressed.summary]) { + expect(s).not.toMatch(/healthy|degraded|critical|no action needed/i); + } + }); + + it('counts flagged services instead of statuses', () => { + const report = buildReport({ + ranked: rankServices( + new Map([ + ['a', stats({ errors: 80 })], + ['b', stats()], + ]), + new Map([ + ['a', stats()], + ['b', stats()], + ]), + 50, + ), + current: { start: new Date(0), end: new Date(600_000) }, + comparison: { start: new Date(-600_000), end: new Date(0) }, + comparisonMode: 'previous_period', + spanKind: 'server', + topN: 5, + errorGroupBy: ['SpanName'], + currentErrors: new Map(), + comparisonErrorCounts: new Map(), + newOperations: new Map(), + spanNameKey: 'SpanName', + }); + expect(report.summary.servicesFlagged).toBe(1); + expect(report.summary).not.toHaveProperty('statusCounts'); + }); + it('does not call errors new when the service has no baseline', () => { const svc = reportFor(stats(), stats({ requests: 1, errors: 0 })); expect(svc.flags).toEqual(['new_service']); diff --git a/packages/api/src/mcp/tools/query/serviceHealth/index.ts b/packages/api/src/mcp/tools/query/serviceHealth/index.ts index ae68c7c650..d45d08258d 100644 --- a/packages/api/src/mcp/tools/query/serviceHealth/index.ts +++ b/packages/api/src/mcp/tools/query/serviceHealth/index.ts @@ -37,9 +37,9 @@ const TIMEOUT_SUFFIX = const DESCRIPTION = 'One-call health report for every service on a trace source: throughput, ' + 'error rate, p50/p95/p99 latency and top error groups, compared with the ' + - 'same window yesterday and ranked worst first. Each service gets a status ' + - '(critical / degraded / healthy), flags, and a one-line summary of which ' + - 'changes crossed a threshold. New or growing error groups come first, ' + + 'same window yesterday and ranked by how far each moved the wrong way. ' + + 'Each service gets flags for changes that crossed a fixed threshold and ' + + 'a one-line factual summary. New or growing error groups come first, ' + 'with first/last-seen times; operations with no prior traffic are listed ' + 'as likely rollouts.\n\n' + 'USE THIS FIRST for status checks, "is X healthy", post-deploy checks, and ' + @@ -53,8 +53,10 @@ const DESCRIPTION = 'use clickstack_event_deltas and ' + 'clickstack_trace_top_time_consuming_operations.\n\n' + 'CALIBRATION: flags fire only past fixed thresholds (see `usage` in the ' + - 'response). Small swings on a healthy service are normal variance. An ' + - 'all-healthy report is a valid answer.\n\n' + + 'response); smaller swings are usually normal variance, and no flags is ' + + 'a valid result for a routine check. Thresholds apply to service-wide ' + + 'totals, so an unflagged service can still hide a regression in one ' + + 'endpoint, pod or segment. Weigh the numbers against the question.\n\n' + PREFER_BUILDER_OVER_SQL_NUDGE; export function registerServiceHealth({ @@ -172,10 +174,7 @@ export function registerServiceHealth({ 'mcp.service_health.window_ms': current.end.getTime() - current.start.getTime(), 'mcp.service_health.services_assessed': ranked.length, - 'mcp.service_health.critical_count': - report.summary.statusCounts.critical, - 'mcp.service_health.degraded_count': - report.summary.statusCounts.degraded, + 'mcp.service_health.flagged_count': report.summary.servicesFlagged, }); const { data, isTrimmed, notes: trimNotes } = trimToolResponse(report); diff --git a/packages/api/src/mcp/tools/query/serviceHealth/rank.ts b/packages/api/src/mcp/tools/query/serviceHealth/rank.ts index 3c4ebeb1a8..005a357774 100644 --- a/packages/api/src/mcp/tools/query/serviceHealth/rank.ts +++ b/packages/api/src/mcp/tools/query/serviceHealth/rank.ts @@ -8,7 +8,7 @@ export const HEALTH_THRESHOLDS = { errorRateRatio: 2, /** ...and at least this many percentage points higher. */ errorRateMinDeltaPp: 1, - /** high_error_rate, and critical when combined with error_rate_up. */ + /** high_error_rate: current error rate at or above this. */ highErrorRate: 0.05, /** latency_up: p95 at least this multiple of the comparison... */ latencyRatio: 1.5, @@ -34,24 +34,25 @@ export type HealthFlag = | 'latency_up' | 'traffic_drop' | 'missing_service' - // Context for the reader; these never change status. + // Context only; these do not count as a crossed threshold. | 'traffic_spike' | 'new_service' | 'low_volume' // Set by the report: a shown error group is new or growing. | 'new_errors'; -const DEGRADING_FLAGS: ReadonlySet = new Set([ +const THRESHOLD_FLAGS: ReadonlySet = new Set([ 'error_rate_up', 'high_error_rate', 'latency_up', 'traffic_drop', 'missing_service', ]); -export type HealthStatus = 'critical' | 'degraded' | 'healthy'; + +export const crossedThreshold = (flags: readonly HealthFlag[]) => + flags.some(f => THRESHOLD_FLAGS.has(f)); export type ServiceAssessment = { - status: HealthStatus; flags: HealthFlag[]; score: number; deltas: { @@ -62,12 +63,6 @@ export type ServiceAssessment = { }; }; -export const STATUS_ORDER: Record = { - critical: 0, - degraded: 1, - healthy: 2, -}; - const errorRate = (s: WindowStats) => s.requests > 0 ? s.errors / s.requests : 0; @@ -157,16 +152,7 @@ export function assessService( flags.push('traffic_drop'); } - const status: HealthStatus = - flags.includes('error_rate_up') && - curRate >= HEALTH_THRESHOLDS.highErrorRate - ? 'critical' - : flags.some(f => DEGRADING_FLAGS.has(f)) - ? 'degraded' - : 'healthy'; - return { - status, flags, // Too few requests to judge, so it should not outrank real services. score: flags.includes('low_volume') @@ -177,7 +163,7 @@ export function assessService( } /** - * Orders services within a status. Sums how far each metric moved the wrong + * Orders services by magnitude. Sums how far each metric moved the wrong * way, then scales by log volume so a busy service outranks a quiet one with * the same shift. Error points are percentage points; latency and traffic are * log2 ratios, so doubling p95 weighs the same as +2pp errors. @@ -202,7 +188,8 @@ export function compareAssessments( b: { assessment: ServiceAssessment; errors: number; requests: number }, ): number { return ( - STATUS_ORDER[a.assessment.status] - STATUS_ORDER[b.assessment.status] || + Number(crossedThreshold(b.assessment.flags)) - + Number(crossedThreshold(a.assessment.flags)) || b.assessment.score - a.assessment.score || b.errors - a.errors || b.requests - a.requests diff --git a/packages/api/src/mcp/tools/query/serviceHealth/report.ts b/packages/api/src/mcp/tools/query/serviceHealth/report.ts index c5f7fc2b2c..d870907715 100644 --- a/packages/api/src/mcp/tools/query/serviceHealth/report.ts +++ b/packages/api/src/mcp/tools/query/serviceHealth/report.ts @@ -3,11 +3,10 @@ import { MAX_NEW_OPERATIONS, type NewOperation } from './operations'; import { assessService, compareAssessments, + crossedThreshold, HEALTH_THRESHOLDS, type HealthFlag, - type HealthStatus, type ServiceAssessment, - STATUS_ORDER, type WindowStats, } from './rank'; import type { Window } from './schema'; @@ -142,21 +141,25 @@ const iso = (w: Window) => ({ }); const USAGE = - 'Services are sorted by status (critical, degraded, healthy), then score. ' + + 'Services with new or growing errors (new_errors) come first, then ' + + 'services with a threshold flag, then the rest; each group is ordered by ' + + 'score (how far error rate, p95 and traffic moved the wrong way, weighted ' + + 'by volume). Threshold flags: ' + `error_rate_up: error rate >= ${HEALTH_THRESHOLDS.errorRateRatio}x the comparison AND ` + `>= ${HEALTH_THRESHOLDS.errorRateMinDeltaPp} percentage point higher. ` + - `high_error_rate: error rate >= ${HEALTH_THRESHOLDS.highErrorRate * 100}%. ` + - 'critical = error_rate_up together with high_error_rate. ' + + `high_error_rate: error rate >= ${HEALTH_THRESHOLDS.highErrorRate * 100}% (may be chronic; check comparison). ` + `latency_up: p95 >= ${HEALTH_THRESHOLDS.latencyRatio}x AND >= ${HEALTH_THRESHOLDS.latencyMinDeltaMs}ms slower. ` + `traffic_drop: requests <= ${HEALTH_THRESHOLDS.trafficDropRatio * 100}% of the comparison. ` + 'missing_service: served requests in the comparison window but none now. ' + - 'Any of those makes a service degraded. traffic_spike, new_service and ' + - 'low_volume (under minRequests in both windows, never flagged) and ' + - 'new_errors (a listed error group is new or growing; such services sort ' + - 'first within their status) are context only. Healthy services are not ' + - 'regressions: report them as steady, and treat moves below these ' + - "thresholds as normal variance. Each service's " + - '`summary` states which moves crossed one. topErrors carry firstSeen / ' + + 'Context flags: traffic_spike, new_service, low_volume (under ' + + 'minRequests in both windows, never threshold-flagged) and new_errors. ' + + 'Moves below the thresholds are usually normal variance. The tool sees ' + + 'service-wide totals only: a regression confined to one endpoint, pod, ' + + 'version or attribute segment can stay under every threshold, so break ' + + 'down with clickstack_table before ruling a service out of an ' + + "investigation. Each service's `summary` states the facts: which moves " + + 'crossed a threshold, or the current vs comparison values when none did. ' + + 'topErrors carry firstSeen / ' + 'lastSeen and a pattern: ongoing (still firing), spread (most of the ' + 'window, quiet now; not proven resolved) or burst (short, so transient). topErrors lists new and ' + 'growing error groups first (largest gain first), then the largest steady ' + @@ -192,12 +195,9 @@ export function buildReport(args: { ); const windowMinutes = (args.current.end.getTime() - args.current.start.getTime()) / 60_000; - const counts: Record = { - critical: 0, - degraded: 0, - healthy: 0, - }; - for (const r of args.ranked) counts[r.assessment.status]++; + const servicesFlagged = args.ranked.filter(r => + crossedThreshold(r.assessment.flags), + ).length; const prior = (g: ErrorGroup) => args.comparisonErrorCounts?.get(errorGroupKey(g)) ?? 0; @@ -208,23 +208,16 @@ export function buildReport(args: { ) : []; const hasNewErrors = (r: RankedService) => changedErrors(r).length > 0; - // Within a status, a service with a new or growing error group goes first: - // its aggregate rates may be flat, but it has the most novel signal. + // A service with a new or growing error group goes first: its aggregate + // rates may be flat, but it has the most novel signal. const shown = args.ranked .map((r, i) => ({ r, i, novel: hasNewErrors(r) })) - .sort( - (a, b) => - STATUS_ORDER[a.r.assessment.status] - - STATUS_ORDER[b.r.assessment.status] || - Number(b.novel) - Number(a.novel) || - a.i - b.i, - ) + .sort((a, b) => Number(b.novel) - Number(a.novel) || a.i - b.i) .slice(0, args.topN); const services = shown.map(({ r, novel }, i) => ({ rank: i + 1, service: r.service, - status: r.assessment.status, flags: novel ? [...r.assessment.flags, 'new_errors' satisfies HealthFlag] : r.assessment.flags, @@ -281,7 +274,7 @@ export function buildReport(args: { errorGroupBy: args.errorGroupBy, servicesAssessed: args.ranked.length, servicesReturned: services.length, - statusCounts: counts, + servicesFlagged, ...(args.ranked.length === 0 && { warning: 'No spans matched in either window. Check the time window, the ' + diff --git a/packages/api/src/mcp/tools/query/serviceHealth/summary.ts b/packages/api/src/mcp/tools/query/serviceHealth/summary.ts index a7737af20a..4aa745e2e7 100644 --- a/packages/api/src/mcp/tools/query/serviceHealth/summary.ts +++ b/packages/api/src/mcp/tools/query/serviceHealth/summary.ts @@ -1,6 +1,7 @@ import type { ErrorGroup } from './errorGroups'; import type { NewOperation } from './operations'; import { + crossedThreshold, HEALTH_THRESHOLDS, type ServiceAssessment, type WindowStats, @@ -59,9 +60,10 @@ function describeError(g: ErrorGroup, window: Window): string { } /** - * A plain verdict per service. Agents otherwise read every nonzero delta as a - * trend ("error rate up 0.08pp"), so this states which moves crossed a - * threshold and which are ordinary variance. + * States which moves crossed a threshold, or the raw values when none did. + * Agents read every nonzero delta as a trend ("error rate up 0.08pp"), but a + * verdict like "healthy, no action needed" stops them investigating a + * regression the service-wide totals hide, so this reports facts only. */ export function serviceSummary(args: { current: WindowStats | null; @@ -92,14 +94,13 @@ export function serviceSummary(args: { const head = parts.length > 0 - ? `${assessment.status.toUpperCase()}: ${parts.join('; ')}.` + ? `Crossed thresholds: ${parts.join('; ')}.` : comparison === undefined - ? 'Healthy: no absolute threshold crossed (no comparison window).' - : `Healthy: no metric crossed a threshold vs ${args.comparisonMode}; ` + - 'the deltas are normal variance' + - (changedErrors.length > 0 - ? ', but see the new errors below.' - : '; report this service as steady, no action needed.'); + ? `No absolute threshold crossed (no comparison window): error rate ${pct(current)}, p95 ${current?.p95Ms ?? 'n/a'}ms.` + : `No metric crossed a threshold vs ${args.comparisonMode}: ` + + `error rate ${pct(current)} vs ${pct(comparison)}, ` + + `p95 ${current?.p95Ms ?? 'n/a'}ms vs ${comparison?.p95Ms ?? 'n/a'}ms, ` + + `requests ${current?.requests ?? 0} vs ${comparison?.requests ?? 0}.`; const context: string[] = []; if (f.has('new_service')) @@ -139,7 +140,7 @@ export function serviceSummary(args: { : ''), ); } - if (args.spanKind === 'all' && assessment.status === 'healthy') + if (args.spanKind === 'all' && !crossedThreshold(assessment.flags)) context.push( 'Internal and client spans are included, which dilutes server-side ' + 'changes; check spanKind:"server" before concluding nothing changed.', From 1394aecf1a2c6b8589521e634585c29697c765b1 Mon Sep 17 00:00:00 2001 From: Brandon Pereira Date: Thu, 12 Nov 2026 12:18:10 -0600 Subject: [PATCH 2/2] fix(mcp): keep flagged services ahead of new-error ones in service health An unflagged service with new errors could push a threshold-flagged one out of topN. Also print n/a instead of "n/ams" for a missing p95, pin the no-comparison summary, and note the span attribute rename (review major, minor, deep-review P2/P3). --- .changeset/service-health-no-verdict.md | 4 +- .../query/__tests__/serviceHealthRank.test.ts | 68 +++++++++++++++++++ .../mcp/tools/query/serviceHealth/report.ts | 28 +++++--- .../mcp/tools/query/serviceHealth/summary.ts | 5 +- 4 files changed, 94 insertions(+), 11 deletions(-) diff --git a/.changeset/service-health-no-verdict.md b/.changeset/service-health-no-verdict.md index 855697d73f..31d1df9e56 100644 --- a/.changeset/service-health-no-verdict.md +++ b/.changeset/service-health-no-verdict.md @@ -8,4 +8,6 @@ The tool no longer labels services critical, degraded or healthy, and no longer tells agents a service needs "no action". Each service keeps its threshold flags, score and a factual summary, so agents reason from the numbers instead of a verdict that service-wide totals can get wrong. `summary.statusCounts` is -replaced by `summary.servicesFlagged`. +replaced by `summary.servicesFlagged`, and the span attributes +`mcp.service_health.critical_count` and `mcp.service_health.degraded_count` are +replaced by `mcp.service_health.flagged_count`. diff --git a/packages/api/src/mcp/tools/query/__tests__/serviceHealthRank.test.ts b/packages/api/src/mcp/tools/query/__tests__/serviceHealthRank.test.ts index f7138d3b8f..1486889f59 100644 --- a/packages/api/src/mcp/tools/query/__tests__/serviceHealthRank.test.ts +++ b/packages/api/src/mcp/tools/query/__tests__/serviceHealthRank.test.ts @@ -281,6 +281,45 @@ describe('buildReport', () => { expect(report.services[0].flags).toContain('new_errors'); }); + it('keeps a flagged service ahead of an unflagged one with new errors', () => { + const report = buildReport({ + ranked: rankServices( + new Map([ + ['slow', stats({ p95Ms: 600 })], + ['quiet', stats()], + ]), + new Map([ + ['slow', stats()], + ['quiet', stats()], + ]), + 50, + ), + current: { start: new Date(0), end: new Date(600_000) }, + comparison: { start: new Date(-600_000), end: new Date(0) }, + comparisonMode: 'previous_period', + spanKind: 'server', + topN: 1, + errorGroupBy: ['SpanName', 'StatusMessage'], + currentErrors: new Map([ + [ + 'quiet', + [ + { + service: 'quiet', + group: { SpanName: 'POST /pay', StatusMessage: 'unavailable' }, + count: 8, + }, + ], + ], + ]), + comparisonErrorCounts: new Map(), + newOperations: new Map(), + spanNameKey: 'SpanName', + }); + expect(report.services.map(s => s.service)).toEqual(['slow']); + expect(report.summary.servicesFlagged).toBe(1); + }); + const reportFor = ( cur: WindowStats, cmp: WindowStats, @@ -407,6 +446,35 @@ describe('errorTiming', () => { expect(errorTiming(group('17:20', '17:21'), window).pattern).toBe('burst'); }); + it('states absolute values when comparisons are off', () => { + expect( + serviceSummary({ + current: stats(), + comparison: undefined, + assessment: assessService(stats(), undefined, 50), + changedErrors: [], + newOperations: [], + window, + comparisonMode: 'none', + }), + ).toBe( + 'No absolute threshold crossed (no comparison window): error rate 0.50%, p95 200ms.', + ); + }); + + it('prints n/a, not "n/ams", for a missing p95', () => { + const summary = serviceSummary({ + current: stats(), + comparison: stats({ p95Ms: null }), + assessment: assessService(stats(), stats({ p95Ms: null }), 50), + changedErrors: [], + newOperations: [], + window, + comparisonMode: 'yesterday', + }); + expect(summary).toMatch(/p95 200ms vs n\/a,/); + }); + it('only calls bursts transient in the summary', () => { const summary = (g: ReturnType) => serviceSummary({ diff --git a/packages/api/src/mcp/tools/query/serviceHealth/report.ts b/packages/api/src/mcp/tools/query/serviceHealth/report.ts index d870907715..d9a147790f 100644 --- a/packages/api/src/mcp/tools/query/serviceHealth/report.ts +++ b/packages/api/src/mcp/tools/query/serviceHealth/report.ts @@ -141,10 +141,10 @@ const iso = (w: Window) => ({ }); const USAGE = - 'Services with new or growing errors (new_errors) come first, then ' + - 'services with a threshold flag, then the rest; each group is ordered by ' + - 'score (how far error rate, p95 and traffic moved the wrong way, weighted ' + - 'by volume). Threshold flags: ' + + 'Services with a threshold flag come first, then the rest. Within each ' + + 'group, services with new or growing errors (new_errors) come first, ' + + 'then by score (how far error rate, p95 and traffic moved the wrong way, ' + + 'weighted by volume). Threshold flags: ' + `error_rate_up: error rate >= ${HEALTH_THRESHOLDS.errorRateRatio}x the comparison AND ` + `>= ${HEALTH_THRESHOLDS.errorRateMinDeltaPp} percentage point higher. ` + `high_error_rate: error rate >= ${HEALTH_THRESHOLDS.highErrorRate * 100}% (may be chronic; check comparison). ` + @@ -208,11 +208,23 @@ export function buildReport(args: { ) : []; const hasNewErrors = (r: RankedService) => changedErrors(r).length > 0; - // A service with a new or growing error group goes first: its aggregate - // rates may be flat, but it has the most novel signal. + // Within the flagged and unflagged groups, a service with a new or growing + // error group goes first: its aggregate rates may be flat, but it has the + // most novel signal. Flagged services stay ahead so topN never drops one + // that servicesFlagged counts. const shown = args.ranked - .map((r, i) => ({ r, i, novel: hasNewErrors(r) })) - .sort((a, b) => Number(b.novel) - Number(a.novel) || a.i - b.i) + .map((r, i) => ({ + r, + i, + novel: hasNewErrors(r), + flagged: crossedThreshold(r.assessment.flags), + })) + .sort( + (a, b) => + Number(b.flagged) - Number(a.flagged) || + Number(b.novel) - Number(a.novel) || + a.i - b.i, + ) .slice(0, args.topN); const services = shown.map(({ r, novel }, i) => ({ diff --git a/packages/api/src/mcp/tools/query/serviceHealth/summary.ts b/packages/api/src/mcp/tools/query/serviceHealth/summary.ts index 4aa745e2e7..dc826f320f 100644 --- a/packages/api/src/mcp/tools/query/serviceHealth/summary.ts +++ b/packages/api/src/mcp/tools/query/serviceHealth/summary.ts @@ -21,6 +21,7 @@ type ErrorPattern = 'ongoing' | 'spread' | 'burst'; const pct = (s: WindowStats | null) => s && s.requests > 0 ? `${((s.errors / s.requests) * 100).toFixed(2)}%` : '0%'; +const fmtMs = (v: number | null | undefined) => (v == null ? 'n/a' : `${v}ms`); const hhmm = (ms: number) => new Date(ms).toISOString().slice(11, 16); export function errorTiming(g: ErrorGroup, window: Window) { @@ -96,10 +97,10 @@ export function serviceSummary(args: { parts.length > 0 ? `Crossed thresholds: ${parts.join('; ')}.` : comparison === undefined - ? `No absolute threshold crossed (no comparison window): error rate ${pct(current)}, p95 ${current?.p95Ms ?? 'n/a'}ms.` + ? `No absolute threshold crossed (no comparison window): error rate ${pct(current)}, p95 ${fmtMs(current?.p95Ms)}.` : `No metric crossed a threshold vs ${args.comparisonMode}: ` + `error rate ${pct(current)} vs ${pct(comparison)}, ` + - `p95 ${current?.p95Ms ?? 'n/a'}ms vs ${comparison?.p95Ms ?? 'n/a'}ms, ` + + `p95 ${fmtMs(current?.p95Ms)} vs ${fmtMs(comparison?.p95Ms)}, ` + `requests ${current?.requests ?? 0} vs ${comparison?.requests ?? 0}.`; const context: string[] = [];