From 6b37e6e2282c968a1805972d3f987716b56fab48 Mon Sep 17 00:00:00 2001 From: Nathan Hangen Date: Sun, 10 May 2026 21:33:55 -0400 Subject: [PATCH] fix(mcp): skip bytes histogram when sizer throws Previously a sizer exception (e.g. upstream result shape change) recorded bytes=0, silently poisoning the 0-bucket with synthetic data. Skip the histogram observe entirely so distribution stats stay honest. --- gitnexus/src/mcp/metrics.ts | 7 +++---- gitnexus/test/unit/mcp/metrics.test.ts | 22 ++++++++++++++++++++++ 2 files changed, 25 insertions(+), 4 deletions(-) diff --git a/gitnexus/src/mcp/metrics.ts b/gitnexus/src/mcp/metrics.ts index ee428341f..0dc417fb4 100644 --- a/gitnexus/src/mcp/metrics.ts +++ b/gitnexus/src/mcp/metrics.ts @@ -158,13 +158,12 @@ export async function observe( requestsCounter?.add(1, labels); durationHistogram?.record(durationSeconds, labels); if (!errored) { - let bytes = 0; try { - bytes = sizer(result); + const bytes = sizer(result); + resultBytesHistogram?.record(bytes, { tool }); } catch { - bytes = 0; + // Skip the histogram rather than poison the 0-bucket on a shape change. } - resultBytesHistogram?.record(bytes, { tool }); } return result; } catch (err) { diff --git a/gitnexus/test/unit/mcp/metrics.test.ts b/gitnexus/test/unit/mcp/metrics.test.ts index 3bfdbbed2..9c04ea0bf 100644 --- a/gitnexus/test/unit/mcp/metrics.test.ts +++ b/gitnexus/test/unit/mcp/metrics.test.ts @@ -226,6 +226,28 @@ describe('privacy: target_info / scope_info suppression', () => { }); }); +describe('observe() — sizer fault tolerance', () => { + it('skips the bytes histogram when sizer throws (does not record bytes=0)', async () => { + process.env['GITNEXUS_OTEL_METRICS'] = 'on'; + await withRandomPort(async () => { + const init = await initMetrics(); + await observe( + 'list_repos', + async () => ({ content: [{ type: 'text', text: 'ok' }] }), + () => { + throw new Error('shape changed'); + }, + () => false, + ); + const text = await fetch(`http://127.0.0.1:${init.port}/metrics`).then((r) => r.text()); + expect(text).toContain( + 'gitnexus_mcp_tool_requests_total{tool="list_repos",error="false"} 1', + ); + expect(text).not.toMatch(/gitnexus_mcp_tool_result_bytes_count\{tool="list_repos"\}/); + }); + }); +}); + describe('bucket boundary contract', () => { it('publishes the documented duration buckets verbatim', () => { expect(DURATION_BUCKETS_SECONDS).toEqual([0.001, 0.005, 0.025, 0.1, 0.5, 2.5, 10, 30]);