feat(mcp): OTel metrics with Prometheus exporter (PoC for #1351)

Adds server-side observability via the OpenTelemetry metrics SDK with a
Prometheus exporter that exposes a /metrics endpoint. Answers the
maintainer's framing: OpenTelemetry format, anonymous, quantized,
Prometheus.

Instruments (meter `gitnexus.mcp`):
  - gitnexus_mcp_tool_requests_total (Counter) — labels: tool, error
  - gitnexus_mcp_tool_request_duration_seconds (Histogram) — labels: tool, error
  - gitnexus_mcp_tool_result_bytes (Histogram) — labels: tool
  - gitnexus_mcp_tool_inflight (UpDownCounter) — labels: tool

Anonymity is type-enforced at the wrap-point: observe() takes a
boolean error detector only, never a message string. Error messages
echo user input (cypher parse errors include the offending clause) and
are recorded as error=true/false labels only. target_info and
otel_scope_info are suppressed on the exporter so the only label keys
that ever appear are { tool, error, le }.

Histogram buckets:
  - Duration:   [0.001, 0.005, 0.025, 0.1, 0.5, 2.5, 10, 30] s
  - Result size: [512, 2048, 8192, 32768, 131072, 524288, 2097152] B

Opt-in via GITNEXUS_OTEL_METRICS=on/true/1. Default off. Binds 127.0.0.1
by default. `gitnexus serve` is the supported scrape target; stdio mode
spawns one process per agent host, so its metrics opt-in is for local
dev only.

Refs #1351.
This commit is contained in:
Nathan Hangen 2026-05-10 20:49:05 -04:00
parent d69eadfb7f
commit 2d0a4ffef2
7 changed files with 665 additions and 27 deletions

View file

@ -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",

View file

@ -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",

View file

@ -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

View file

@ -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') {

244
gitnexus/src/mcp/metrics.ts Normal file
View file

@ -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 130ms; `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 28 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<InitMetricsResult> {
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<void> {
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<T>(
tool: string,
fn: () => Promise<T>,
sizer: (result: T) => number,
hasError: (result: T) => boolean,
): Promise<T> {
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;
}

View file

@ -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<string, any> | 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<string, any> | 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<void> {
try {
await server.close();
} catch {}
try {
await shutdownMetrics();
} catch {}
const { flushLoggerSync } = await import('../core/logger.js');
flushLoggerSync();
process.exit(exitCode);

View file

@ -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<T>(fn: () => Promise<T>): Promise<T> {
// 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<number>((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<void>((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]);
});
});