opentel-mcp 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md ADDED
@@ -0,0 +1,60 @@
1
+ # Changelog
2
+
3
+ ## 0.3.0
4
+
5
+ ### Added
6
+
7
+ - OTel metrics, via `@opentelemetry/api`'s Metrics API only (no bundled
8
+ SDK/exporter — same host-app-provides-the-SDK pattern tracing already
9
+ uses):
10
+ - `mcp.tool.calls` (counter) — every tool call; `gen_ai.tool.name`,
11
+ `mcp.method.name`
12
+ - `mcp.tool.errors` (counter) — thrown/rejected handler; `gen_ai.tool.name`,
13
+ `error.type`
14
+ - `mcp.tool.silent_failures` (counter) — JSON-RPC succeeded but
15
+ `CallToolResult.isError === true`; `gen_ai.tool.name`
16
+ - `mcp.tool.duration` (histogram, unit `ms`) — call latency;
17
+ `gen_ai.tool.name`, `mcp.tool.outcome` (`success` | `error` |
18
+ `silent_failure`)
19
+ - `mcp.tool.silent_failures` fires from the same `isError` check that
20
+ marks the span ERROR — extracted into one shared `isToolResultError()`
21
+ helper in `src/instrument.js` so the detection logic isn't duplicated
22
+ between traces and metrics.
23
+ - Metrics are a zero-overhead no-op until the host application registers
24
+ a `MeterProvider` (default `@opentelemetry/api` behavior — not
25
+ special-cased here).
26
+ - `enableMetrics` option (default `true`) to opt out of metric emission
27
+ without affecting tracing.
28
+
29
+ ### Naming note (no attribute rename)
30
+
31
+ The tool-name attribute on all four new metrics is `gen_ai.tool.name`, not
32
+ `mcp.tool.name` — the same spec-aligned name spans have used since v0.2's
33
+ semantic-conventions pass (ADR 004). Traces and metrics were already
34
+ consistent going into this release, so nothing was renamed here; this is
35
+ called out because a naive read of the MCP semantic conventions might
36
+ suggest a `mcp.tool.name` attribute, but the spec's actual server-span/
37
+ metric attribute for this is `gen_ai.tool.name` (MCP tool calls are
38
+ GenAI `execute_tool` calls under the hood — see `docs/adr/004-semantic-conventions-alignment.md`
39
+ and `.spec-reference/mcp-semconv.md`). `mcp.method.name`, `error.type`,
40
+ and `mcp.tool.argument_count` are unchanged. `mcp.tool.outcome` is a new
41
+ custom (non-spec) attribute, documented in `src/attributes.js` alongside
42
+ the other custom attribute.
43
+
44
+ ### Docs
45
+
46
+ - README: new "Metrics" section (instrument table, `enableMetrics`, and a
47
+ `PeriodicExportingMetricReader` + OTLP/HTTP wiring example targeting
48
+ SigNoz's default local endpoint).
49
+ - Roadmap updated to reflect metrics shipping in this release.
50
+
51
+ ## 0.2.0
52
+
53
+ See git history — TypeScript declarations (`.d.ts`), workspace stripping
54
+ for publish, and README documentation improvements.
55
+
56
+ ## 0.1.0
57
+
58
+ Initial release: OTel tracing for MCP tool calls, including detection of
59
+ `CallToolResult.isError: true` "silent failures" as `error.type: tool_error`
60
+ span status.
package/README.md CHANGED
@@ -2,7 +2,8 @@
2
2
 
3
3
  > OpenTelemetry instrumentation for Model Context Protocol (MCP) servers.
4
4
  > One-line visibility into which tools your AI agent is calling, how
5
- > long they take, and which ones fail — via standard OTel traces.
5
+ > long they take, and which ones fail — via standard OTel traces and
6
+ > metrics.
6
7
 
7
8
  [![CI](https://github.com/Thirumalaiboobathi/opentel-mcp/actions/workflows/ci.yml/badge.svg)](https://github.com/Thirumalaiboobathi/opentel-mcp/actions/workflows/ci.yml)
8
9
  [![npm version](https://img.shields.io/npm/v/opentel-mcp.svg)](https://www.npmjs.com/package/opentel-mcp)
@@ -32,6 +33,65 @@ invocation with rich attributes, using standard OpenTelemetry APIs so
32
33
  it plugs into your existing observability stack (Jaeger, Grafana Tempo,
33
34
  Honeycomb, Datadog, whatever).
34
35
 
36
+ ## What you get
37
+
38
+ MCP tool calls are invisible by default. When an agent turn takes 2.5
39
+ seconds, you have no idea which tool caused it. opentel-mcp turns every
40
+ tool call into an OpenTelemetry span you can read.
41
+
42
+ ### Example trace
43
+
44
+ One agent turn, captured in Jaeger (spans generated by opentel-mcp,
45
+ exported over OTLP):
46
+
47
+ ```
48
+ agent_turn ............................. 2470ms OK
49
+ ├─ tools/call search_documents ........... 46ms OK
50
+ ├─ tools/call fetch_weather ............. 601ms OK
51
+ └─ tools/call generate_advice .......... 1802ms OK ← the bottleneck
52
+ ```
53
+
54
+ Without tracing you'd know only that the turn took ~2.5s. With it, the
55
+ waterfall shows `generate_advice` is where the time goes —
56
+ `search_documents` and `fetch_weather` are cheap by comparison. That's
57
+ the difference between guessing and knowing.
58
+
59
+ ### Tool-level failures
60
+
61
+ An MCP tool can fail two ways: it can throw, or it can return
62
+ `isError: true` on an otherwise-successful response. The second is how a
63
+ tool reports a failure to the agent without crashing the server — and
64
+ most instrumentation records it as a success, because the call
65
+ technically succeeded.
66
+
67
+ opentel-mcp catches it:
68
+
69
+ ```
70
+ tools/call fetch_weather ................. 605ms ERROR
71
+ error.type = tool_error
72
+ ```
73
+
74
+ The span is marked ERROR with `error.type = tool_error`, the result is
75
+ returned to the agent unchanged, and nothing is thrown. Your error rate
76
+ reflects reality instead of reading as a flat 100% success.
77
+
78
+ ### Span attributes
79
+
80
+ Every span follows the OpenTelemetry MCP semantic conventions, so it
81
+ drops into existing dashboards (Jaeger, Grafana Tempo, Honeycomb, SigNoz)
82
+ with no custom mapping:
83
+
84
+ | Attribute | Example value |
85
+ |---|---|
86
+ | `mcp.method.name` | `tools/call` |
87
+ | `gen_ai.tool.name` | `generate_advice` |
88
+ | `gen_ai.operation.name` | `execute_tool` |
89
+ | `jsonrpc.request.id` | `3` |
90
+ | `mcp.tool.argument_count` | `2` |
91
+
92
+ Span name follows the spec's `{method} {target}` form (e.g.
93
+ `tools/call generate_advice`), and span kind is `SERVER`.
94
+
35
95
  ## Quickstart (5-line usage)
36
96
 
37
97
  Works with either the low-level `Server` API:
@@ -110,6 +170,95 @@ Span status description carries the error message on failure (thrown
110
170
  errors); no separate error-message attribute is emitted — the spec
111
171
  expresses success/failure through span status, not a status attribute.
112
172
 
173
+ ## Metrics
174
+
175
+ Alongside spans, opentel-mcp emits four `mcp.tool.*` metrics via
176
+ `@opentelemetry/api`'s Metrics API — same API-only pattern as tracing (see
177
+ "Two modes" below): no SDK or exporter is bundled, and recording is a
178
+ zero-overhead no-op until the host application registers a
179
+ `MeterProvider`. Set `enableMetrics: false` to opt out even when one is
180
+ registered; tracing is unaffected either way.
181
+
182
+ | Instrument | Type | Unit | Attributes | Emitted when |
183
+ |---|---|---|---|---|
184
+ | `mcp.tool.calls` | Counter | — | `gen_ai.tool.name`, `mcp.method.name` | Every tool call |
185
+ | `mcp.tool.errors` | Counter | — | `gen_ai.tool.name`, `error.type` | The handler threw or its promise rejected |
186
+ | `mcp.tool.silent_failures` | Counter | — | `gen_ai.tool.name` | The JSON-RPC response succeeded but `CallToolResult.isError` was `true` |
187
+ | `mcp.tool.duration` | Histogram | `ms` | `gen_ai.tool.name`, `mcp.tool.outcome` (`success` \| `error` \| `silent_failure`) | Every tool call, on completion |
188
+
189
+ `mcp.tool.silent_failures` is incremented from the exact same `isError`
190
+ check that marks the span ERROR (see `isToolResultError()` in
191
+ `src/instrument.js`) — the detection logic isn't duplicated between traces
192
+ and metrics. `gen_ai.tool.name`, `mcp.method.name`, and `error.type` are
193
+ the same spec-aligned attribute names the spans already use (see "Span
194
+ attributes emitted" above); `mcp.tool.outcome` is a custom (non-spec)
195
+ attribute, documented in `src/attributes.js`, that lets a single duration
196
+ histogram distinguish thrown/protocol errors from silent failures without
197
+ joining against `error.type`, which silent failures don't set on this
198
+ metric.
199
+
200
+ ### Wiring a MeterProvider and TracerProvider (example: SigNoz)
201
+
202
+ opentel-mcp never creates a `MeterProvider` itself (except the stderr/dev
203
+ `setupNodeSdk: true` tracing path, which doesn't touch metrics) — register
204
+ one the same way you would for any OTel-instrumented Node app, before
205
+ calling `instrumentMcpServer()`. The same is true of the `TracerProvider`
206
+ in bring-your-own-SDK mode (`setupNodeSdk: false`, the default): without
207
+ registering one, `trace.getTracer()` inside opentel-mcp resolves to the
208
+ `@opentelemetry/api` no-op default and spans are silently dropped, even
209
+ though `mcp.tool.*` metrics keep flowing:
210
+
211
+ ```js
212
+ import { metrics, trace } from '@opentelemetry/api';
213
+ import { MeterProvider, PeriodicExportingMetricReader } from '@opentelemetry/sdk-metrics';
214
+ import { OTLPMetricExporter } from '@opentelemetry/exporter-metrics-otlp-http';
215
+ import { NodeTracerProvider, BatchSpanProcessor } from '@opentelemetry/sdk-trace-node';
216
+ import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-http';
217
+ import { resourceFromAttributes } from '@opentelemetry/resources';
218
+ import { Server } from '@modelcontextprotocol/sdk/server/index.js';
219
+ import { instrumentMcpServer } from 'opentel-mcp';
220
+
221
+ const resource = resourceFromAttributes({ 'service.name': 'my-mcp-server' });
222
+
223
+ const meterProvider = new MeterProvider({
224
+ resource,
225
+ readers: [
226
+ new PeriodicExportingMetricReader({
227
+ exporter: new OTLPMetricExporter({ url: 'http://localhost:4318/v1/metrics' }),
228
+ }),
229
+ ],
230
+ });
231
+ metrics.setGlobalMeterProvider(meterProvider);
232
+
233
+ const tracerProvider = new NodeTracerProvider({
234
+ resource,
235
+ spanProcessors: [new BatchSpanProcessor(new OTLPTraceExporter({ url: 'http://localhost:4318/v1/traces' }))],
236
+ });
237
+ tracerProvider.register();
238
+
239
+ const server = new Server({ name: 'my-server', version: '1.0.0' }, {
240
+ capabilities: { tools: {} },
241
+ });
242
+
243
+ instrumentMcpServer(server, {
244
+ // setupNodeSdk: false (default) — this example brings its own SDK for
245
+ // both signals: the MeterProvider above for metrics, and the
246
+ // NodeTracerProvider above — registered globally via
247
+ // tracerProvider.register(), which is what trace.getTracer() picks up —
248
+ // for traces.
249
+ });
250
+ ```
251
+
252
+ `http://localhost:4318` is SigNoz's default local OTLP/HTTP endpoint (both
253
+ `/v1/metrics` and `/v1/traces`); point it at your collector/SigNoz
254
+ ingestion endpoint in production. `@opentelemetry/sdk-metrics` and
255
+ `@opentelemetry/exporter-metrics-otlp-http` are peer/host-app
256
+ dependencies — opentel-mcp doesn't bundle them (see the peerDependencies
257
+ note in `package.json`). `@opentelemetry/sdk-trace-node` and
258
+ `@opentelemetry/exporter-trace-otlp-http`, by contrast, are already
259
+ runtime dependencies of opentel-mcp itself (used by its `setupNodeSdk: true`
260
+ dev path), so no extra install is needed to use them here.
261
+
113
262
  ## Two modes
114
263
 
115
264
  **One-line (dev):** `setupNodeSdk: true` sets up a NodeTracerProvider
@@ -144,10 +293,11 @@ See ADR 002 in docs/adr/ for why.
144
293
 
145
294
  ## Roadmap
146
295
 
147
- - v0.2: OTel metrics (`mcp.server.operation.duration`,
148
- `mcp.server.session.duration`) alongside traces, plus opt-in
149
- `gen_ai.tool.call.arguments` support with a redaction callback
150
- - v0.3: W3C trace context propagation via `params._meta` per
296
+ - Shipped in v0.3: `mcp.tool.*` metrics (see "Metrics" above). Opt-in
297
+ `gen_ai.tool.call.arguments` support with a redaction callback, and the
298
+ spec's own `mcp.server.operation.duration` / `mcp.server.session.duration`
299
+ metrics, remain unimplemented tracked here, not silently dropped.
300
+ - Next: W3C trace context propagation via `params._meta` per
151
301
  [SEP-414](https://modelcontextprotocol.io/community/seps/414-request-meta),
152
302
  plus client-side instrumentation, so a single trace can span the client
153
303
  call and the server's tool execution
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opentel-mcp",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "One-line OpenTelemetry instrumentation for Model Context Protocol (MCP) servers",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
@@ -14,6 +14,7 @@
14
14
  "files": [
15
15
  "src",
16
16
  "README.md",
17
+ "CHANGELOG.md",
17
18
  "LICENSE"
18
19
  ],
19
20
  "engines": {
@@ -56,6 +57,7 @@
56
57
  },
57
58
  "devDependencies": {
58
59
  "@opentelemetry/api": "^1.9.0",
60
+ "@opentelemetry/sdk-metrics": "^2.9.0",
59
61
  "@opentelemetry/sdk-trace-base": "^2.9.0",
60
62
  "typescript": "^7.0.2",
61
63
  "vitest": "^2.1.8"
package/src/attributes.js CHANGED
@@ -63,3 +63,25 @@ export const ATTR_MCP_TOOL_ARGUMENT_COUNT = 'mcp.tool.argument_count';
63
63
 
64
64
  export const ATTR_MCP_SERVER_NAME = 'mcp.server.name';
65
65
  export const ATTR_MCP_SERVER_VERSION = 'mcp.server.version';
66
+
67
+ /**
68
+ * NOT part of the MCP semantic conventions. Our own addition, on the
69
+ * mcp.tool.duration histogram (see src/metrics.js): which of the three
70
+ * call outcomes a given duration measurement belongs to. The spec's
71
+ * mcp.server.operation.duration metric (not yet implemented here — see
72
+ * README roadmap) expresses failure only via error.type; this attribute
73
+ * additionally distinguishes "thrown/protocol error" from "silent failure"
74
+ * (isError: true) so both are visible on the same histogram without
75
+ * requiring a join against error.type, which silent failures don't set
76
+ * on the duration metric.
77
+ */
78
+ export const ATTR_MCP_TOOL_OUTCOME = 'mcp.tool.outcome';
79
+
80
+ /** Well-known mcp.tool.outcome value: the call succeeded. */
81
+ export const MCP_TOOL_OUTCOME_SUCCESS = 'success';
82
+
83
+ /** Well-known mcp.tool.outcome value: the handler threw or its promise rejected. */
84
+ export const MCP_TOOL_OUTCOME_ERROR = 'error';
85
+
86
+ /** Well-known mcp.tool.outcome value: isError: true (see ERROR_TYPE_TOOL_ERROR above). */
87
+ export const MCP_TOOL_OUTCOME_SILENT_FAILURE = 'silent_failure';
package/src/config.js CHANGED
@@ -16,6 +16,10 @@ import { diag } from '@opentelemetry/api';
16
16
  * Only takes effect when `setupNodeSdk` is true.
17
17
  * @property {boolean} [enabled=true] - Set to false to disable instrumentation entirely; instrumentMcpServer()
18
18
  * becomes a no-op.
19
+ * @property {boolean} [enableMetrics=true] - Set to false to disable the mcp.tool.* metrics (tracing is
20
+ * unaffected). Metrics are already a zero-overhead no-op when no MeterProvider is registered — the default
21
+ * @opentelemetry/api behavior — so this flag exists for opting out even when one *is* registered, not as a
22
+ * substitute for that default.
19
23
  * @property {boolean} [setupNodeSdk=false] - When true, instrumentMcpServer() creates and registers its own
20
24
  * NodeTracerProvider (always exporting to stderr — safe alongside stdio-transport MCP servers, see ADR 003;
21
25
  * additionally to `exporterUrl` via OTLP/HTTP if set). When false (the default), spans are emitted via
@@ -66,6 +70,7 @@ export function resolveOptions(options) {
66
70
  serviceName: opts.serviceName,
67
71
  exporterUrl: opts.exporterUrl,
68
72
  enabled: opts.enabled ?? true,
73
+ enableMetrics: opts.enableMetrics ?? true,
69
74
  setupNodeSdk,
70
75
  };
71
76
  }
package/src/index.d.ts CHANGED
@@ -32,6 +32,19 @@ export interface InstrumentOptions {
32
32
  */
33
33
  enabled?: boolean;
34
34
 
35
+ /**
36
+ * Set to `false` to disable the `mcp.tool.*` metrics. Tracing is
37
+ * unaffected.
38
+ *
39
+ * Metrics are already a zero-overhead no-op when no `MeterProvider` is
40
+ * registered (the default `@opentelemetry/api` behavior) — this flag is
41
+ * for opting out of metrics even when a `MeterProvider` **is**
42
+ * registered, not a substitute for that default.
43
+ *
44
+ * @default true
45
+ */
46
+ enableMetrics?: boolean;
47
+
35
48
  /**
36
49
  * When `true`, {@link instrumentMcpServer} creates and registers its own
37
50
  * `NodeTracerProvider` (always exporting to stderr; additionally to
package/src/instrument.js CHANGED
@@ -11,6 +11,7 @@ import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-http';
11
11
  import { resourceFromAttributes } from '@opentelemetry/resources';
12
12
  import { resolveOptions } from './config.js';
13
13
  import { StderrSpanExporter } from './exporters/stderr.js';
14
+ import { setupMeter } from './metrics.js';
14
15
  import {
15
16
  ATTR_MCP_METHOD_NAME,
16
17
  ATTR_GEN_AI_TOOL_NAME,
@@ -21,6 +22,9 @@ import {
21
22
  ERROR_TYPE_TOOL_ERROR,
22
23
  GEN_AI_OPERATION_NAME_EXECUTE_TOOL,
23
24
  MCP_METHOD_NAME_TOOLS_CALL,
25
+ MCP_TOOL_OUTCOME_SUCCESS,
26
+ MCP_TOOL_OUTCOME_ERROR,
27
+ MCP_TOOL_OUTCOME_SILENT_FAILURE,
24
28
  } from './attributes.js';
25
29
 
26
30
  const require = createRequire(import.meta.url);
@@ -130,6 +134,7 @@ export function instrumentMcpServer(input, options) {
130
134
  assertInstrumentFirst(server);
131
135
 
132
136
  const tracer = setupTracer(server, resolved);
137
+ const metricsRecorder = resolved.enableMetrics ? setupMeter(PACKAGE_VERSION) : null;
133
138
  if (outer && server.shutdown) {
134
139
  outer.shutdown = server.shutdown;
135
140
  }
@@ -137,7 +142,7 @@ export function instrumentMcpServer(input, options) {
137
142
  const originalSetRequestHandler = server.setRequestHandler.bind(server);
138
143
  server.setRequestHandler = (schema, handler) => {
139
144
  if (schema === CallToolRequestSchema) {
140
- handler = wrapToolCallHandler(handler, tracer);
145
+ handler = wrapToolCallHandler(handler, tracer, metricsRecorder);
141
146
  }
142
147
  return originalSetRequestHandler(schema, handler);
143
148
  };
@@ -207,9 +212,27 @@ function setupTracer(server, resolved) {
207
212
  }
208
213
 
209
214
  /**
210
- * Wraps a tools/call handler in a span covering its execution. This sits as
211
- * the innermost layer relative to Server's own request/response validation
212
- * wrapping (see ADR 001), so the span times exactly the real handler logic.
215
+ * True when `result` is a JSON-RPC-successful CallToolResult carrying a
216
+ * tool-level failure (isError: true) the "silent failure" this package
217
+ * exists to catch (see the README's "Tool-level failures" section). Single
218
+ * source of truth for that detection: wrapToolCallHandler below keys both
219
+ * the span's status/error.type and the mcp.tool.silent_failures counter /
220
+ * mcp.tool.duration outcome off this same check, rather than repeating
221
+ * `result?.isError === true` at each call site.
222
+ *
223
+ * @param {*} result
224
+ * @returns {boolean}
225
+ */
226
+ function isToolResultError(result) {
227
+ return result?.isError === true;
228
+ }
229
+
230
+ /**
231
+ * Wraps a tools/call handler in a span covering its execution, plus the
232
+ * mcp.tool.* metrics (see src/metrics.js). This sits as the innermost layer
233
+ * relative to Server's own request/response validation wrapping (see ADR
234
+ * 001), so both the span and the duration measurement time exactly the
235
+ * real handler logic.
213
236
  *
214
237
  * Span shape follows the MCP semantic conventions' server span (ADR 004):
215
238
  * name `{mcp.method.name} {target}` (falling back to just the method name
@@ -217,14 +240,15 @@ function setupTracer(server, resolved) {
217
240
  * error.type is set — which happens either because the handler threw, or
218
241
  * because it resolved successfully but returned a CallToolResult with
219
242
  * isError: true (a JSON-RPC-level success carrying a tool-level failure;
220
- * the spec calls this error.type value "tool_error"). In the isError case
221
- * the result is returned unchanged and nothing is thrown — the JSON-RPC
222
- * call itself succeeded.
243
+ * the spec calls this error.type value "tool_error", see
244
+ * isToolResultError() above). In the isError case the result is returned
245
+ * unchanged and nothing is thrown — the JSON-RPC call itself succeeded.
223
246
  *
224
247
  * @param {Function} handler
225
248
  * @param {import('@opentelemetry/api').Tracer} tracer
249
+ * @param {ReturnType<import('./metrics.js').setupMeter> | null} metricsRecorder
226
250
  */
227
- function wrapToolCallHandler(handler, tracer) {
251
+ function wrapToolCallHandler(handler, tracer, metricsRecorder) {
228
252
  return (request, extra) => {
229
253
  const toolName = request?.params?.name;
230
254
  const spanName = toolName ? `${TOOLS_CALL_METHOD} ${toolName}` : TOOLS_CALL_METHOD;
@@ -240,19 +264,28 @@ function wrapToolCallHandler(handler, tracer) {
240
264
  span.setAttribute(ATTR_JSONRPC_REQUEST_ID, String(extra.requestId));
241
265
  }
242
266
 
267
+ metricsRecorder?.recordCall(toolName);
268
+ const startTime = performance.now();
269
+
243
270
  try {
244
271
  const result = await handler(request, extra);
245
- if (result?.isError === true) {
272
+ if (isToolResultError(result)) {
246
273
  span.setAttribute(ATTR_ERROR_TYPE, ERROR_TYPE_TOOL_ERROR);
247
274
  span.setStatus({ code: SpanStatusCode.ERROR });
275
+ metricsRecorder?.recordSilentFailure(toolName);
276
+ metricsRecorder?.recordDuration(toolName, performance.now() - startTime, MCP_TOOL_OUTCOME_SILENT_FAILURE);
248
277
  } else {
249
278
  span.setStatus({ code: SpanStatusCode.OK });
279
+ metricsRecorder?.recordDuration(toolName, performance.now() - startTime, MCP_TOOL_OUTCOME_SUCCESS);
250
280
  }
251
281
  return result;
252
282
  } catch (err) {
253
283
  span.recordException(err);
254
284
  span.setStatus({ code: SpanStatusCode.ERROR, message: err?.message });
255
- span.setAttribute(ATTR_ERROR_TYPE, err?.name ?? 'Error');
285
+ const errorType = err?.name ?? 'Error';
286
+ span.setAttribute(ATTR_ERROR_TYPE, errorType);
287
+ metricsRecorder?.recordError(toolName, errorType);
288
+ metricsRecorder?.recordDuration(toolName, performance.now() - startTime, MCP_TOOL_OUTCOME_ERROR);
256
289
  throw err;
257
290
  } finally {
258
291
  span.end();
package/src/metrics.js ADDED
@@ -0,0 +1,87 @@
1
+ /**
2
+ * @module metrics
3
+ * OpenTelemetry Metrics instruments for opentel-mcp.
4
+ *
5
+ * Follows the same @opentelemetry/api-only pattern as tracing (see
6
+ * setupTracer() in instrument.js): instruments come from
7
+ * metrics.getMeter(), which resolves to whatever MeterProvider the host
8
+ * application has already registered globally, or the API's built-in
9
+ * no-op implementation if none has. This module never creates or
10
+ * registers a MeterProvider itself — recording through it is a
11
+ * zero-overhead no-op until the host does. See config.js's enableMetrics
12
+ * option for the opt-out, and the README's "Metrics" section for how to
13
+ * wire up a real MeterProvider.
14
+ *
15
+ * Unlike @opentelemetry/api's tracing API, its metrics API (as of
16
+ * @opentelemetry/api ^1.9) does not proxy a not-yet-registered
17
+ * MeterProvider — metrics.getMeter() resolves the current global
18
+ * synchronously at call time rather than lazily delegating later. So, as
19
+ * with setupTracer(), setupMeter() must run after the host application has
20
+ * registered its MeterProvider (the same ordering the README already
21
+ * documents for tracing).
22
+ */
23
+
24
+ import { metrics } from '@opentelemetry/api';
25
+ import {
26
+ ATTR_MCP_METHOD_NAME,
27
+ ATTR_GEN_AI_TOOL_NAME,
28
+ ATTR_ERROR_TYPE,
29
+ ATTR_MCP_TOOL_OUTCOME,
30
+ MCP_METHOD_NAME_TOOLS_CALL,
31
+ } from './attributes.js';
32
+
33
+ /**
34
+ * Creates the mcp.tool.* instruments and returns small record*() wrappers
35
+ * around them, keyed to the well-known attribute names above so call
36
+ * sites in instrument.js never construct attribute bags by hand.
37
+ *
38
+ * @param {string} packageVersion
39
+ * @returns {{
40
+ * recordCall: (toolName: string | undefined) => void,
41
+ * recordError: (toolName: string | undefined, errorType: string) => void,
42
+ * recordSilentFailure: (toolName: string | undefined) => void,
43
+ * recordDuration: (toolName: string | undefined, durationMs: number, outcome: string) => void,
44
+ * }}
45
+ */
46
+ export function setupMeter(packageVersion) {
47
+ const meter = metrics.getMeter('opentel-mcp', packageVersion);
48
+
49
+ const calls = meter.createCounter('mcp.tool.calls', {
50
+ description: 'Number of MCP tool calls received, regardless of outcome.',
51
+ });
52
+ const errors = meter.createCounter('mcp.tool.errors', {
53
+ description: 'Number of MCP tool calls whose handler threw or rejected (protocol-level failure).',
54
+ });
55
+ const silentFailures = meter.createCounter('mcp.tool.silent_failures', {
56
+ description:
57
+ 'Number of MCP tool calls whose JSON-RPC response succeeded but whose CallToolResult had isError: true.',
58
+ });
59
+ const duration = meter.createHistogram('mcp.tool.duration', {
60
+ description: 'Duration of MCP tool call execution.',
61
+ unit: 'ms',
62
+ });
63
+
64
+ return {
65
+ recordCall(toolName) {
66
+ calls.add(1, {
67
+ [ATTR_GEN_AI_TOOL_NAME]: toolName,
68
+ [ATTR_MCP_METHOD_NAME]: MCP_METHOD_NAME_TOOLS_CALL,
69
+ });
70
+ },
71
+ recordError(toolName, errorType) {
72
+ errors.add(1, {
73
+ [ATTR_GEN_AI_TOOL_NAME]: toolName,
74
+ [ATTR_ERROR_TYPE]: errorType,
75
+ });
76
+ },
77
+ recordSilentFailure(toolName) {
78
+ silentFailures.add(1, { [ATTR_GEN_AI_TOOL_NAME]: toolName });
79
+ },
80
+ recordDuration(toolName, durationMs, outcome) {
81
+ duration.record(durationMs, {
82
+ [ATTR_GEN_AI_TOOL_NAME]: toolName,
83
+ [ATTR_MCP_TOOL_OUTCOME]: outcome,
84
+ });
85
+ },
86
+ };
87
+ }