diff --git a/gitnexus/package-lock.json b/gitnexus/package-lock.json index dfc5e3c4c..52e792824 100644 --- a/gitnexus/package-lock.json +++ b/gitnexus/package-lock.json @@ -13,6 +13,11 @@ "@huggingface/transformers": "^4.1.0", "@ladybugdb/core": "^0.16.1", "@modelcontextprotocol/sdk": "^1.0.0", + "@opentelemetry/api": "^1.9.1", + "@opentelemetry/exporter-prometheus": "^0.217.0", + "@opentelemetry/resources": "^2.7.1", + "@opentelemetry/sdk-metrics": "^2.7.1", + "@opentelemetry/semantic-conventions": "^1.40.0", "@scarf/scarf": "^1.4.0", "cli-progress": "^3.12.0", "commander": "^14.0.3", @@ -63,7 +68,7 @@ "vitest": "^4.0.18" }, "engines": { - "node": ">=20.0.0" + "node": ">=22.0.0" }, "optionalDependencies": { "node-addon-api": "^8.0.0", @@ -1571,6 +1576,89 @@ "@emnapi/runtime": "^1.7.1" } }, + "node_modules/@opentelemetry/api": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.1.tgz", + "integrity": "sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q==", + "license": "Apache-2.0", + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/@opentelemetry/core": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.7.1.tgz", + "integrity": "sha512-QAqIj32AtK6+pEVNG7EOVxHdE06RP+FM5qpiEJ4RtDcFIqKUZHYhl7/7UY5efhwmwNAg7j8QbJVBLxMerc0+gw==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/exporter-prometheus": { + "version": "0.217.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/exporter-prometheus/-/exporter-prometheus-0.217.0.tgz", + "integrity": "sha512-U9MCXxJu0sBCh5aEkylYRR4xVIL8D1CW6dGwvYXbfFr0qveSorfD0XJchCAWoW6QfAAIcY/yxjf4Dj8OgkHBPw==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.7.1", + "@opentelemetry/resources": "2.7.1", + "@opentelemetry/sdk-metrics": "2.7.1", + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/resources": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.7.1.tgz", + "integrity": "sha512-DeT6KKolmC4e/dRQvMQ/RwlnzhaqeiFOXY5ngoOPJ07GgVVKxZOg9EcrNZb5aTzUn+iCrJldAgOfQm1O/QfPAQ==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.7.1", + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.3.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/sdk-metrics": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-metrics/-/sdk-metrics-2.7.1.tgz", + "integrity": "sha512-MpDJdkiFDs3Pm1RHO3KByuZbuBdJEXEAkiC0+yJdsZGVCdf1RpHR6n+LHDcS7ffmfrt5kVCzJSCfm4z2C7v0uQ==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.7.1", + "@opentelemetry/resources": "2.7.1" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.9.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/semantic-conventions": { + "version": "1.40.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.40.0.tgz", + "integrity": "sha512-cifvXDhcqMwwTlTK04GBNeIe7yyo28Mfby85QXFe1Yk8nmi36Ab/5UQwptOx84SsoGNRg+EVSjwzfSZMy6pmlw==", + "license": "Apache-2.0", + "engines": { + "node": ">=14" + } + }, "node_modules/@oxc-project/types": { "version": "0.126.0", "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.126.0.tgz", diff --git a/gitnexus/package.json b/gitnexus/package.json index 633c69f24..f448ec613 100644 --- a/gitnexus/package.json +++ b/gitnexus/package.json @@ -56,6 +56,11 @@ "@huggingface/transformers": "^4.1.0", "@ladybugdb/core": "^0.16.1", "@modelcontextprotocol/sdk": "^1.0.0", + "@opentelemetry/api": "^1.9.1", + "@opentelemetry/exporter-prometheus": "^0.217.0", + "@opentelemetry/resources": "^2.7.1", + "@opentelemetry/sdk-metrics": "^2.7.1", + "@opentelemetry/semantic-conventions": "^1.40.0", "@scarf/scarf": "^1.4.0", "cli-progress": "^3.12.0", "commander": "^14.0.3", diff --git a/gitnexus/src/cli/mcp.ts b/gitnexus/src/cli/mcp.ts index 056b12591..6c0924799 100644 --- a/gitnexus/src/cli/mcp.ts +++ b/gitnexus/src/cli/mcp.ts @@ -48,12 +48,35 @@ export const mcpCommand = async () => { // stdout at module init, but transitive deps (pino, pino-pretty, the // worker-thread transport) could in theory, and the import-closure // regression test enforces the leaf invariant. - const [{ startMCPServer }, { LocalBackend }, { logger }] = await Promise.all([ + const [{ startMCPServer }, { LocalBackend }, { logger }, { initMetrics }] = await Promise.all([ import('../mcp/server.js'), import('../mcp/local/local-backend.js'), import('../core/logger.js'), + import('../mcp/metrics.js'), ]); + // OpenTelemetry metrics + Prometheus exporter. Opt-in via GITNEXUS_OTEL_METRICS. + // In stdio mode (this entrypoint), each agent host spawns its own process, so + // metrics are off by default. Operators who want server-side observability + // should run `gitnexus serve` and scrape that instance instead. The opt-in + // path still works here for local development / single-process setups. + try { + const result = await initMetrics(); + if (result.enabled) { + logger.info( + { port: result.port, host: result.host, endpoint: result.endpoint }, + 'GitNexus: Prometheus metrics endpoint listening', + ); + } + } catch (err: any) { + // Metrics must never crash the server. EADDRINUSE here is the common case; + // log and continue without metrics. + logger.warn( + { err: err?.message ?? String(err) }, + 'GitNexus: failed to start metrics endpoint — continuing without metrics', + ); + } + // Missing-optional-grammar warnings are intentionally NOT emitted here. // `gitnexus analyze` already warns at index time, filtered by the repo's // actual extensions, and a repo can only be served by MCP after analyze diff --git a/gitnexus/src/cli/serve.ts b/gitnexus/src/cli/serve.ts index 9356b5bab..a0aedf1c3 100644 --- a/gitnexus/src/cli/serve.ts +++ b/gitnexus/src/cli/serve.ts @@ -32,6 +32,26 @@ export const serveCommand = async (options?: { port?: string; host?: string }) = const host = options?.host ?? 'localhost'; try { + // OpenTelemetry metrics + Prometheus exporter. Opt-in via + // GITNEXUS_OTEL_METRICS. `gitnexus serve` is the supported scrape target + // (see src/mcp/metrics.ts for the rationale). + try { + const { initMetrics } = await import('../mcp/metrics.js'); + const result = await initMetrics(); + if (result.enabled) { + logger.info( + { port: result.port, host: result.host, endpoint: result.endpoint }, + '[gitnexus serve] Prometheus metrics endpoint listening', + ); + } + } catch (metricsErr: any) { + // Never crash the server because of metrics. Log and continue. + logger.warn( + { err: metricsErr?.message ?? String(metricsErr) }, + '[gitnexus serve] failed to start metrics endpoint — continuing without metrics', + ); + } + await createServer(port, host); } catch (err: any) { if (err.code === 'EADDRINUSE') { diff --git a/gitnexus/src/mcp/metrics.ts b/gitnexus/src/mcp/metrics.ts new file mode 100644 index 000000000..c390cf326 --- /dev/null +++ b/gitnexus/src/mcp/metrics.ts @@ -0,0 +1,244 @@ +/** + * MCP server-side observability — OpenTelemetry metrics with Prometheus exposition. + * + * Answers issue #1351: provides ground-truth "is this MCP server being used?" + * for operators who scrape `/metrics`. + * + * Design constraints from the maintainer's framing: + * - OpenTelemetry format (not custom JSON, not pino). + * - Anonymous: no per-call identifiers, no input data, no error message text. + * Cypher parse errors echo user input, so error is recorded as a boolean only. + * - Prometheus: pull model via `/metrics`. No collector required. + * - Quantized: histograms with explicit buckets. No raw per-call values escape. + * + * Privacy boundary is type-enforced: observe() takes `(result) => boolean`, + * never a message-returning detector. The error label is `error="true|false"`, + * never a message. + * + * Initialization is opt-in via GITNEXUS_OTEL_METRICS=on. Bound to 127.0.0.1 by + * default. The exporter is configured to suppress `target_info` and scope info + * so the only labels emitted are `tool` (closed set) and `error` (boolean). + * + * Stdio mode emits no metrics by default — each agent host spawns its own + * process and binding ephemeral ports is operationally useless. Supported + * scrape target is `gitnexus serve`. + */ + +import type { Counter, Histogram, UpDownCounter } from '@opentelemetry/api'; +import { MeterProvider, AggregationType } from '@opentelemetry/sdk-metrics'; +import { PrometheusExporter } from '@opentelemetry/exporter-prometheus'; + +const METER_NAME = 'gitnexus.mcp'; + +// Quantized buckets. Once published, these become a dashboard contract; +// boundaries are intentionally coarse so we don't have to recut them. +// +// Duration: tools that hit the in-memory graph (`context`, `query` on a small +// repo) commonly return in 1–30ms; `impact` / `detect_changes` on a large repo +// can run multiple seconds. 1ms..30s covers cold-cache outliers. +export const DURATION_BUCKETS_SECONDS = [0.001, 0.005, 0.025, 0.1, 0.5, 2.5, 10, 30]; + +// Result size: tool responses range from a few-hundred-byte error envelopes to +// multi-megabyte `context`/`cypher` payloads. The modal `context`/`route_map` +// answer lands in 2–8 KB; boundaries are chosen so that range straddles a +// boundary rather than collapsing into one bucket. +export const RESULT_BYTES_BUCKETS = [512, 2048, 8192, 32768, 131072, 524288, 2097152]; + +let provider: MeterProvider | null = null; +let exporter: PrometheusExporter | null = null; +let requestsCounter: Counter | null = null; +let durationHistogram: Histogram | null = null; +let resultBytesHistogram: Histogram | null = null; +let inflightGauge: UpDownCounter | null = null; + +function envFlag(value: string | undefined): boolean { + if (typeof value !== 'string') return false; + const v = value.trim().toLowerCase(); + return v === 'on' || v === 'true' || v === '1'; +} + +export interface InitMetricsOptions { + /** + * Override the env-driven master switch. Use in tests to force initialization + * without setting process env. + */ + forceEnabled?: boolean; +} + +export interface InitMetricsResult { + enabled: boolean; + port?: number; + host?: string; + endpoint?: string; +} + +/** + * Initialize the OTel meter provider and start the Prometheus exporter HTTP + * server. No-op when GITNEXUS_OTEL_METRICS is unset, empty, off, false, or 0. + * + * Safe to call multiple times — second and later calls return the existing + * state without re-binding the port. + */ +export async function initMetrics( + opts: InitMetricsOptions = {}, +): Promise { + const env = process.env; + const enabled = opts.forceEnabled ?? envFlag(env['GITNEXUS_OTEL_METRICS']); + if (!enabled) return { enabled: false }; + + if (provider !== null) { + return { + enabled: true, + port: Number(env['GITNEXUS_OTEL_METRICS_PORT'] ?? 9464), + host: env['GITNEXUS_OTEL_METRICS_HOST'] ?? '127.0.0.1', + endpoint: env['GITNEXUS_OTEL_METRICS_ENDPOINT'] ?? '/metrics', + }; + } + + const port = Number(env['GITNEXUS_OTEL_METRICS_PORT'] ?? 9464); + const host = env['GITNEXUS_OTEL_METRICS_HOST'] ?? '127.0.0.1'; + const endpoint = env['GITNEXUS_OTEL_METRICS_ENDPOINT'] ?? '/metrics'; + + exporter = new PrometheusExporter({ + host, + port, + endpoint, + // Privacy: suppress target_info (would ship every resource attribute as a + // separate metric series) and otel_scope_info. Only labels we want to + // expose are `tool` and `error`. + withoutTargetInfo: true, + withoutScopeInfo: true, + appendTimestamp: false, + }); + + provider = new MeterProvider({ + readers: [exporter], + views: [ + { + instrumentName: 'gitnexus_mcp_tool_request_duration_seconds', + aggregation: { + type: AggregationType.EXPLICIT_BUCKET_HISTOGRAM, + options: { boundaries: DURATION_BUCKETS_SECONDS, recordMinMax: false }, + }, + }, + { + instrumentName: 'gitnexus_mcp_tool_result_bytes', + aggregation: { + type: AggregationType.EXPLICIT_BUCKET_HISTOGRAM, + options: { boundaries: RESULT_BYTES_BUCKETS, recordMinMax: false }, + }, + }, + ], + }); + + // Use the provider directly rather than the global registration. Each + // process gets exactly one provider (init is idempotent above); skipping the + // global avoids state-leak across vitest fork boundaries when tests cycle + // init/shutdown repeatedly. + const meter = provider.getMeter(METER_NAME); + + requestsCounter = meter.createCounter('gitnexus_mcp_tool_requests_total', { + description: 'Total MCP tool invocations, labelled by tool name and error outcome.', + }); + + durationHistogram = meter.createHistogram('gitnexus_mcp_tool_request_duration_seconds', { + description: 'End-to-end MCP tool handler latency in seconds.', + unit: 's', + }); + + resultBytesHistogram = meter.createHistogram('gitnexus_mcp_tool_result_bytes', { + description: 'Size of the MCP tool result text payload in bytes.', + unit: 'By', + }); + + inflightGauge = meter.createUpDownCounter('gitnexus_mcp_tool_inflight', { + description: 'Currently executing MCP tool handlers, labelled by tool.', + }); + + // Wait until the exporter HTTP server is actually accepting connections so + // callers can integration-test the endpoint immediately after init. + await exporter.startServer(); + + return { enabled: true, port, host, endpoint }; +} + +/** + * Shut down the meter provider and exporter HTTP server. Safe to call when + * init was a no-op (resets nothing). Use in graceful-shutdown paths. + */ +export async function shutdownMetrics(): Promise { + const p = provider; + const e = exporter; + provider = null; + exporter = null; + requestsCounter = null; + durationHistogram = null; + resultBytesHistogram = null; + inflightGauge = null; + if (e) await e.shutdown().catch(() => {}); + if (p) await p.shutdown().catch(() => {}); +} + +/** + * Wrap an MCP tool handler with metric instrumentation. Records: + * - requests counter (incremented once per call, with `error` label) + * - duration histogram (in seconds) + * - result bytes histogram (only on success — error-path size is meaningless) + * - in-flight up/down counter + * + * The `hasError` detector returns a boolean only. The result's error message + * is intentionally not accepted at this seam — error messages echo user input + * (cypher parse errors include the offending clause) and are an anonymity + * leak. + * + * No-op when init was disabled: the metric handles are null, calls are + * skipped, work runs untouched. + */ +export async function observe( + tool: string, + fn: () => Promise, + sizer: (result: T) => number, + hasError: (result: T) => boolean, +): Promise { + if (provider === null) return fn(); + + const attrs = { tool }; + inflightGauge?.add(1, attrs); + const start = process.hrtime.bigint(); + try { + const result = await fn(); + const durationSeconds = Number(process.hrtime.bigint() - start) / 1e9; + const errored = hasError(result); + const labels = { tool, error: errored ? 'true' : 'false' }; + requestsCounter?.add(1, labels); + durationHistogram?.record(durationSeconds, labels); + if (!errored) { + let bytes = 0; + try { + bytes = sizer(result); + } catch { + bytes = 0; + } + resultBytesHistogram?.record(bytes, { tool }); + } + return result; + } catch (err) { + // Thrown errors are converted to error envelopes by the MCP handler upstream, + // so this branch is reached only on truly exceptional failures (transport, + // sentinel, programming errors). Record as error=true with zero bytes. + const durationSeconds = Number(process.hrtime.bigint() - start) / 1e9; + const labels = { tool, error: 'true' }; + requestsCounter?.add(1, labels); + durationHistogram?.record(durationSeconds, labels); + throw err; + } finally { + inflightGauge?.add(-1, attrs); + } +} + +/** + * Returns true if metrics are currently initialized. Test helper. + */ +export function isMetricsEnabled(): boolean { + return provider !== null; +} diff --git a/gitnexus/src/mcp/server.ts b/gitnexus/src/mcp/server.ts index 5159b12d9..4894d826f 100644 --- a/gitnexus/src/mcp/server.ts +++ b/gitnexus/src/mcp/server.ts @@ -27,6 +27,7 @@ import { GITNEXUS_TOOLS } from './tools.js'; import { installGlobalStdoutSentinel } from './stdio-context.js'; import type { LocalBackend } from './local/local-backend.js'; import { getResourceDefinitions, getResourceTemplates, readResource } from './resources.js'; +import { observe, shutdownMetrics } from './metrics.js'; /** * Next-step hints appended to tool responses. @@ -162,35 +163,56 @@ export function createMCPServer(backend: LocalBackend): Server { })), })); - // Handle tool calls — append next-step hints to guide agent workflow + // Handle tool calls — append next-step hints to guide agent workflow. + // The body is wrapped in `observe()` (see `./metrics.ts`) so each call is + // recorded against the OTel meter when metrics are enabled. The wrapper is + // a no-op when metrics are disabled. server.setRequestHandler(CallToolRequestSchema, async (request) => { const { name, arguments: args } = request.params; - try { - const result = await backend.callTool(name, args); - const resultText = typeof result === 'string' ? result : JSON.stringify(result, null, 2); - const hint = getNextStepHint(name, args as Record | undefined); + return observe( + name, + async () => { + try { + const result = await backend.callTool(name, args); + const resultText = + typeof result === 'string' ? result : JSON.stringify(result, null, 2); + const hint = getNextStepHint(name, args as Record | undefined); - return { - content: [ - { - type: 'text', - text: resultText + hint, - }, - ], - }; - } catch (error) { - const message = error instanceof Error ? error.message : 'Unknown error'; - return { - content: [ - { - type: 'text', - text: `Error: ${message}`, - }, - ], - isError: true, - }; - } + return { + content: [ + { + type: 'text' as const, + text: resultText + hint, + }, + ], + }; + } catch (error) { + const message = error instanceof Error ? error.message : 'Unknown error'; + return { + content: [ + { + type: 'text' as const, + text: `Error: ${message}`, + }, + ], + isError: true, + }; + } + }, + (result) => { + // Size = bytes of the text payload only. The envelope structure + // itself is not user data. + const block = result.content?.[0]; + if (block && 'text' in block && typeof block.text === 'string') { + return Buffer.byteLength(block.text, 'utf8'); + } + return 0; + }, + // hasError must return a boolean only. Never accept the error message + // string at this seam — messages echo user input. + (result) => result.isError === true, + ); }); // Handle list prompts request @@ -327,6 +349,9 @@ export async function startMCPServer(backend: LocalBackend): Promise { try { await server.close(); } catch {} + try { + await shutdownMetrics(); + } catch {} const { flushLoggerSync } = await import('../core/logger.js'); flushLoggerSync(); process.exit(exitCode); diff --git a/gitnexus/test/unit/mcp/metrics.test.ts b/gitnexus/test/unit/mcp/metrics.test.ts new file mode 100644 index 000000000..a776b4cf3 --- /dev/null +++ b/gitnexus/test/unit/mcp/metrics.test.ts @@ -0,0 +1,233 @@ +/** + * Tests for src/mcp/metrics.ts — the OTel + Prometheus seam for issue #1351. + * + * Two privacy-critical invariants land here: + * 1. `observe()` accepts a boolean error detector only, never a message string. + * (Type-enforced; tested by passing a result with an explicit message and + * asserting the message does not appear in the exposed metrics text.) + * 2. The Prometheus exporter is configured to suppress `target_info` and + * scope info — only `tool` and `error` labels should appear. + * + * Lifecycle: each test that turns metrics on must `shutdownMetrics()` in afterEach, + * otherwise the exporter HTTP server leaks port + handles across tests. + */ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { + initMetrics, + observe, + shutdownMetrics, + isMetricsEnabled, + DURATION_BUCKETS_SECONDS, + RESULT_BYTES_BUCKETS, +} from '../../../src/mcp/metrics.js'; + +const prevEnv = { ...process.env }; + +function clearMetricsEnv(): void { + delete process.env['GITNEXUS_OTEL_METRICS']; + delete process.env['GITNEXUS_OTEL_METRICS_PORT']; + delete process.env['GITNEXUS_OTEL_METRICS_HOST']; + delete process.env['GITNEXUS_OTEL_METRICS_ENDPOINT']; +} + +async function withRandomPort(fn: () => Promise): Promise { + // Use port 0 by hand-rolling a free port — Node http will pick one but the + // exporter wants a concrete number. Bind/listen/close pattern. + const net = await import('node:net'); + const srv = net.createServer(); + const port = await new Promise((resolve, reject) => { + srv.once('error', reject); + srv.listen(0, '127.0.0.1', () => { + const addr = srv.address(); + if (addr && typeof addr === 'object') resolve(addr.port); + else reject(new Error('bad addr')); + }); + }); + await new Promise((resolve) => srv.close(() => resolve())); + process.env['GITNEXUS_OTEL_METRICS_PORT'] = String(port); + process.env['GITNEXUS_OTEL_METRICS_HOST'] = '127.0.0.1'; + return fn(); +} + +beforeEach(() => { + clearMetricsEnv(); +}); + +afterEach(async () => { + await shutdownMetrics(); + for (const k of Object.keys(process.env)) delete process.env[k]; + Object.assign(process.env, prevEnv); +}); + +describe('initMetrics gating', () => { + it('is a no-op when GITNEXUS_OTEL_METRICS is unset', async () => { + const result = await initMetrics(); + expect(result.enabled).toBe(false); + expect(isMetricsEnabled()).toBe(false); + }); + + it('is a no-op for off/false/0/empty', async () => { + for (const v of ['off', 'OFF', 'false', '0', '', ' ']) { + process.env['GITNEXUS_OTEL_METRICS'] = v; + const result = await initMetrics(); + expect(result.enabled, `value=${JSON.stringify(v)}`).toBe(false); + expect(isMetricsEnabled()).toBe(false); + } + }); + + it('initializes for on/true/1', async () => { + process.env['GITNEXUS_OTEL_METRICS'] = 'on'; + await withRandomPort(async () => { + const result = await initMetrics(); + expect(result.enabled).toBe(true); + expect(isMetricsEnabled()).toBe(true); + }); + }); +}); + +describe('observe() — success path', () => { + it('runs the work and returns the result when metrics are disabled', async () => { + const result = await observe( + 'query', + async () => ({ value: 42 }), + (r) => JSON.stringify(r).length, + () => false, + ); + expect(result).toEqual({ value: 42 }); + }); + + it('records a success call against the Prometheus exposition', async () => { + process.env['GITNEXUS_OTEL_METRICS'] = 'on'; + await withRandomPort(async () => { + const init = await initMetrics(); + expect(init.enabled).toBe(true); + + await observe( + 'list_repos', + async () => ({ content: [{ type: 'text', text: 'ok' }] }), + (r) => Buffer.byteLength(r.content[0]!.text, 'utf8'), + (r) => (r as any).isError === true, + ); + + 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', + ); + // Histogram is exported by the OTel Prom format with _bucket / _sum / _count. + expect(text).toContain('gitnexus_mcp_tool_request_duration_seconds_count'); + expect(text).toContain('gitnexus_mcp_tool_result_bytes_count'); + }); + }); +}); + +describe('observe() — error path', () => { + it('records error=true when the result envelope has isError', async () => { + process.env['GITNEXUS_OTEL_METRICS'] = 'on'; + await withRandomPort(async () => { + const init = await initMetrics(); + const SECRET_ECHO = 'attacker-supplied-cypher-clause'; + await observe( + 'cypher', + async () => ({ + content: [{ type: 'text', text: `Error: parse error near ${SECRET_ECHO}` }], + isError: true, + }), + (r) => Buffer.byteLength(r.content[0]!.text, 'utf8'), + (r) => (r as any).isError === true, + ); + + 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="cypher",error="true"} 1'); + // PRIVACY INVARIANT: error messages echo user input. They MUST NOT leak. + expect(text).not.toContain(SECRET_ECHO); + expect(text).not.toContain('parse error'); + }); + }); + + it('rethrows on synchronous failures while still recording duration', async () => { + process.env['GITNEXUS_OTEL_METRICS'] = 'on'; + await withRandomPort(async () => { + const init = await initMetrics(); + await expect( + observe( + 'detect_changes', + async () => { + throw new Error('boom'); + }, + () => 0, + () => false, + ), + ).rejects.toThrow('boom'); + + 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="detect_changes",error="true"} 1', + ); + }); + }); +}); + +describe('observe() — in-flight balance', () => { + it('decrements the gauge in finally even when the work throws', async () => { + process.env['GITNEXUS_OTEL_METRICS'] = 'on'; + await withRandomPort(async () => { + const init = await initMetrics(); + for (let i = 0; i < 3; i++) { + await expect( + observe( + 'impact', + async () => { + throw new Error('x'); + }, + () => 0, + () => false, + ), + ).rejects.toThrow('x'); + } + + const text = await fetch(`http://127.0.0.1:${init.port}/metrics`).then((r) => r.text()); + // After all calls complete (regardless of success/failure), inflight returns to 0. + // Prom emits the latest value; assert it's a "0" reading for impact. + expect(text).toMatch(/gitnexus_mcp_tool_inflight\{tool="impact"\} 0\b/); + }); + }); +}); + +describe('privacy: target_info / scope_info suppression', () => { + it('does not emit target_info or otel_scope_info series', async () => { + process.env['GITNEXUS_OTEL_METRICS'] = 'on'; + await withRandomPort(async () => { + const init = await initMetrics(); + await observe( + 'context', + async () => ({ content: [{ type: 'text', text: 'x' }] }), + (r) => Buffer.byteLength(r.content[0]!.text, 'utf8'), + () => false, + ); + + const text = await fetch(`http://127.0.0.1:${init.port}/metrics`).then((r) => r.text()); + expect(text).not.toMatch(/^target_info/m); + expect(text).not.toMatch(/otel_scope_info/); + // The exposed label set is bounded: only `tool` and `error` ever appear. + const labelLines = text.split('\n').filter((l) => l.includes('gitnexus_mcp_')); + for (const line of labelLines) { + const match = line.match(/\{([^}]*)\}/); + if (!match) continue; + const labels = match[1]!.split(',').map((s) => s.split('=')[0]!.trim()); + for (const k of labels) { + expect(['tool', 'error', 'le']).toContain(k); + } + } + }); + }); +}); + +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]); + }); + + it('publishes the documented result-size buckets verbatim', () => { + expect(RESULT_BYTES_BUCKETS).toEqual([512, 2048, 8192, 32768, 131072, 524288, 2097152]); + }); +});