mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-09-22 00:31:17 +00:00
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:
parent
d69eadfb7f
commit
2d0a4ffef2
7 changed files with 665 additions and 27 deletions
90
gitnexus/package-lock.json
generated
90
gitnexus/package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
244
gitnexus/src/mcp/metrics.ts
Normal 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 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<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;
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
233
gitnexus/test/unit/mcp/metrics.test.ts
Normal file
233
gitnexus/test/unit/mcp/metrics.test.ts
Normal 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]);
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue