opentel-mcp 0.14.0 → 0.15.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 +98 -0
- package/README.md +145 -14
- package/package.json +2 -2
- package/src/exporters/stderr-metrics.js +142 -0
- package/src/index.d.ts +32 -4
- package/src/instrument.js +91 -7
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,103 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.15.0
|
|
4
|
+
|
|
5
|
+
"Make it visible" — no instrumentation changes, no `tools/call` hot-path
|
|
6
|
+
changes. Phase 1 (ADR 022, `docs/adr/022-publish-ui.md`): publishes
|
|
7
|
+
`opentel-mcp-ui`, previously a working but `"private": true`, unpublished
|
|
8
|
+
scaffold with zero mentions in this README. See
|
|
9
|
+
[`opentel-mcp-ui`'s own CHANGELOG](https://www.npmjs.com/package/opentel-mcp-ui)
|
|
10
|
+
for what changed in that package. Phase 2 (ADR 023,
|
|
11
|
+
`docs/adr/023-dev-mode-metrics.md`): dev-mode metrics under
|
|
12
|
+
`setupNodeSdk: true` — see "Added" below.
|
|
13
|
+
|
|
14
|
+
### Added
|
|
15
|
+
|
|
16
|
+
- README: a "See it locally" section near the top, pointing at
|
|
17
|
+
`npx opentel-mcp-ui --demo` — this library had no documented way for a
|
|
18
|
+
new reader to see its own output before installing anything.
|
|
19
|
+
- `setupNodeSdk: true` now also stands up an owned `MeterProvider` (ADR
|
|
20
|
+
023): a `PeriodicExportingMetricReader` exporting all 12 `mcp.tool.*`/
|
|
21
|
+
`mcp.tool.loop.*`/`mcp.tool.schema_drift.*` instruments to stderr every
|
|
22
|
+
5 seconds, via a new `StderrMetricExporter` (compact
|
|
23
|
+
`name{attrs} = value` lines, never a stdout-writing JSON dump — same
|
|
24
|
+
ADR 003 reasoning `StderrSpanExporter` already follows). Gated
|
|
25
|
+
additionally by `enableMetrics` (default `true`, unchanged option).
|
|
26
|
+
Closes the gap where the documented 30-second quickstart emitted
|
|
27
|
+
stderr spans but none of its 12 metrics. `setupNodeSdk: false` (the
|
|
28
|
+
default, and the production posture) is completely unaffected — every
|
|
29
|
+
new code path is gated behind the same existing `setupNodeSdk === true`
|
|
30
|
+
check `StderrSpanExporter` already uses.
|
|
31
|
+
- If a `MeterProvider` is already registered globally when this runs
|
|
32
|
+
(your own, or anything else's), this is a complete no-op: nothing is
|
|
33
|
+
overridden, no exception is thrown, and a single `diag.warn` (once
|
|
34
|
+
per process, not once per `instrumentMcpServer()` call) names the
|
|
35
|
+
situation. Detected via `@opentelemetry/api`'s own documented
|
|
36
|
+
`boolean` return from `setGlobalMeterProvider()` — not by inspecting
|
|
37
|
+
internals. The abandoned candidate provider built before that check
|
|
38
|
+
is shut down immediately (fire-and-forget), closing a leak where its
|
|
39
|
+
own periodic reader would otherwise tick forever, unused, in the
|
|
40
|
+
background.
|
|
41
|
+
- `server.shutdown()` (already attached under `setupNodeSdk: true`) now
|
|
42
|
+
also flushes and shuts down this `MeterProvider`, alongside the
|
|
43
|
+
existing `TracerProvider` shutdown it already performed.
|
|
44
|
+
- `StderrMetricExporter` only reprints a metric when its value has
|
|
45
|
+
actually changed since the last export. Every instrument here is
|
|
46
|
+
CUMULATIVE, so without this a single early tool call would otherwise
|
|
47
|
+
reprint two unchanged lines every 5 seconds for the rest of the
|
|
48
|
+
process's life — confirmed empirically before this fix shipped, not
|
|
49
|
+
assumed.
|
|
50
|
+
|
|
51
|
+
### Changed
|
|
52
|
+
|
|
53
|
+
- **`@opentelemetry/sdk-metrics` is now a runtime (`dependencies`, not
|
|
54
|
+
`devDependencies`) dependency of `opentel-mcp`, installed for every
|
|
55
|
+
user — including under `setupNodeSdk: false`, which never executes any
|
|
56
|
+
code from it.** Needed at import time for the feature above; moving it
|
|
57
|
+
to `dependencies` means `npm install opentel-mcp` always pulls it in,
|
|
58
|
+
regardless of whether a given deployment ever sets `setupNodeSdk: true`.
|
|
59
|
+
Adds no new transitive dependency: its own two dependencies
|
|
60
|
+
(`@opentelemetry/core`, `@opentelemetry/resources`) were already in
|
|
61
|
+
this package's tree via `@opentelemetry/sdk-trace-node`/the existing
|
|
62
|
+
`@opentelemetry/resources` dependency. See ADR 023's "Dependency
|
|
63
|
+
strategy" section for the full reasoning, including why an optional-peer
|
|
64
|
+
+ dynamic-import approach was considered and rejected.
|
|
65
|
+
|
|
66
|
+
### Fixed
|
|
67
|
+
|
|
68
|
+
- The example servers (`examples/hello-server`, `examples/hello-mcpserver`)
|
|
69
|
+
never showed a metrics line in their own READMEs' "What you'll see": a
|
|
70
|
+
single piped `tools/call` request exits the process before the 5-second
|
|
71
|
+
export interval. Both now flush once via `process.on('beforeExit', ...)`
|
|
72
|
+
right before the process would otherwise exit — confirmed
|
|
73
|
+
`StdioServerTransport` does not call its own `close()`/`onclose` on
|
|
74
|
+
stdin EOF, so `beforeExit` is the one hook that still allows the async
|
|
75
|
+
flush to complete first.
|
|
76
|
+
- Documentation only, no code/behavior changes:
|
|
77
|
+
- README claimed "Four `mcp.tool.*` metrics" in a way that read as the
|
|
78
|
+
library's total metric count. Corrected: four is this section's own
|
|
79
|
+
base call/duration metrics; twelve exist in total across cost/token
|
|
80
|
+
attribution (2), Agent Thrash Detection (5), and schema drift (1),
|
|
81
|
+
each already documented in their own sections.
|
|
82
|
+
- README's test count was stale at every point it was checked this
|
|
83
|
+
release; now 1017 tests, 1013 passing + 4 intentionally skipped.
|
|
84
|
+
- README's SigNoz metrics-wiring example described
|
|
85
|
+
`@opentelemetry/sdk-metrics` as a host-app dependency opentel-mcp
|
|
86
|
+
"doesn't bundle" — true before this release, no longer true now that
|
|
87
|
+
it's a real dependency (see "Changed" above).
|
|
88
|
+
|
|
89
|
+
### CI
|
|
90
|
+
|
|
91
|
+
- `dependency-audit` (`.github/workflows/ci.yml`) no longer audits this
|
|
92
|
+
monorepo's own shared, hoisted `node_modules` — `npm audit`, even with
|
|
93
|
+
`--omit=dev` and run from inside `packages/core/`, scans the entire
|
|
94
|
+
installed tree regardless of cwd or `--workspace` (confirmed
|
|
95
|
+
empirically), so it was reporting findings from `examples/*`'s real
|
|
96
|
+
`@modelcontextprotocol/sdk` dependency (express/hono/ajv transitives)
|
|
97
|
+
as if they were this library's own. Now packs and installs each of
|
|
98
|
+
`packages/core`/`packages/ui` into its own clean, throwaway project —
|
|
99
|
+
the exact tree a real `npm install` produces — and audits that instead.
|
|
100
|
+
|
|
3
101
|
## 0.14.0
|
|
4
102
|
|
|
5
103
|
Adds `errorRecording.redactor`: a host-supplied hook for content
|
package/README.md
CHANGED
|
@@ -18,6 +18,26 @@ to your tools' code.
|
|
|
18
18
|
opentel-mcp is the only Node.js MCP instrumentation library that ties
|
|
19
19
|
tool calls to LLM cost.
|
|
20
20
|
|
|
21
|
+
## See it locally
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
npx opentel-mcp-ui --demo
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
Opens a dashboard at `http://127.0.0.1:4319` — no MCP server, no
|
|
28
|
+
Prometheus, no Collector to stand up first. It's seeded with realistic
|
|
29
|
+
fixture data showing exactly what this README is about: tool calls that
|
|
30
|
+
returned `isError: true` inside a successful response, rendered as the
|
|
31
|
+
clean spans a standard OTel setup would show, side by side with what
|
|
32
|
+
opentel-mcp actually caught.
|
|
33
|
+
|
|
34
|
+
<!-- TODO: screenshot/GIF of the dashboard here -->
|
|
35
|
+
|
|
36
|
+
Drop `--demo` and point your own instrumented server's `exporterUrl` at
|
|
37
|
+
it to see real traffic instead — see
|
|
38
|
+
[`opentel-mcp-ui`'s README](https://www.npmjs.com/package/opentel-mcp-ui)
|
|
39
|
+
for the two-minute walkthrough.
|
|
40
|
+
|
|
21
41
|
## The problem
|
|
22
42
|
|
|
23
43
|
Your AI agent calls 15 MCP tools across 3 servers this turn. One tool
|
|
@@ -38,6 +58,15 @@ npm install opentel-mcp @opentelemetry/api
|
|
|
38
58
|
|
|
39
59
|
opentel-mcp is an ES module — add `"type": "module"` to package.json.
|
|
40
60
|
|
|
61
|
+
If you're starting from a blank project to try the quickstart below
|
|
62
|
+
(rather than adding opentel-mcp to an MCP server you already have), you
|
|
63
|
+
also need the MCP SDK and `zod` — the quickstart's own server and tool
|
|
64
|
+
definitions, not opentel-mcp's dependencies:
|
|
65
|
+
|
|
66
|
+
```bash
|
|
67
|
+
npm install @modelcontextprotocol/sdk zod
|
|
68
|
+
```
|
|
69
|
+
|
|
41
70
|
## 30-second quickstart
|
|
42
71
|
|
|
43
72
|
```js
|
|
@@ -50,7 +79,7 @@ const server = new McpServer({ name: 'my-server', version: '1.0.0' });
|
|
|
50
79
|
|
|
51
80
|
// Wraps every tool registered below. Must run BEFORE server.tool() —
|
|
52
81
|
// see "Ordering constraint" below for why.
|
|
53
|
-
instrumentMcpServer(server, {
|
|
82
|
+
const instrumented = instrumentMcpServer(server, {
|
|
54
83
|
serviceName: 'my-mcp-server', // shows up on your traces
|
|
55
84
|
setupNodeSdk: true, // dev mode: prints traces to your terminal
|
|
56
85
|
});
|
|
@@ -62,6 +91,17 @@ server.tool('echo', { text: z.string() }, async ({ text }) => ({
|
|
|
62
91
|
|
|
63
92
|
const transport = new StdioServerTransport();
|
|
64
93
|
await server.connect(transport);
|
|
94
|
+
|
|
95
|
+
// Only needed to see metrics (below) immediately when piping ONE request
|
|
96
|
+
// in and exiting right after, like the "See it working" example does —
|
|
97
|
+
// a real, long-running server sees the same lines on their own, every 5
|
|
98
|
+
// seconds, with no extra code.
|
|
99
|
+
let flushed = false;
|
|
100
|
+
process.on('beforeExit', () => {
|
|
101
|
+
if (flushed) return;
|
|
102
|
+
flushed = true;
|
|
103
|
+
instrumented.shutdown();
|
|
104
|
+
});
|
|
65
105
|
```
|
|
66
106
|
|
|
67
107
|
That's it. Every tool call now emits a trace. Wire an exporter to see them
|
|
@@ -87,6 +127,21 @@ attributes: {
|
|
|
87
127
|
No dashboard needed — `setupNodeSdk: true`'s dev exporter printed this
|
|
88
128
|
directly. Point it at a real backend later; see "Two modes" below.
|
|
89
129
|
|
|
130
|
+
**Metrics print too (v0.15.0+)** — within 5 seconds (not the default
|
|
131
|
+
60-second export interval), a compact one-line-per-metric summary follows
|
|
132
|
+
on stderr, reusing the exact `mcp.tool.*` instruments documented in
|
|
133
|
+
"Metrics" below:
|
|
134
|
+
|
|
135
|
+
```
|
|
136
|
+
[opentel-mcp metrics] mcp.tool.calls{gen_ai.tool.name=echo,mcp.method.name=tools/call} = 1
|
|
137
|
+
[opentel-mcp metrics] mcp.tool.duration{gen_ai.tool.name=echo,mcp.tool.outcome=success} count=1 avg=0.4ms
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
No extra configuration, no `@opentelemetry/sdk-metrics` setup of your
|
|
141
|
+
own — `setupNodeSdk: true` now does for metrics what it's always done for
|
|
142
|
+
traces. See "Dev-mode metrics" below for exactly what this does and
|
|
143
|
+
doesn't change.
|
|
144
|
+
|
|
90
145
|
---
|
|
91
146
|
|
|
92
147
|
The rest of this README goes deeper: both server APIs, every attribute
|
|
@@ -240,7 +295,17 @@ Runnable end-to-end coverage lives in `test/instrument.v2.test.js` and
|
|
|
240
295
|
|
|
241
296
|
An MCP tool can fail two ways: it can throw, or it can return
|
|
242
297
|
`isError: true` on an otherwise-successful response (the case from "The
|
|
243
|
-
problem" above)
|
|
298
|
+
problem" above) — exactly like the quickstart's `echo` tool, but with
|
|
299
|
+
`isError: true` added and no `throw` involved:
|
|
300
|
+
|
|
301
|
+
```js
|
|
302
|
+
server.tool('fetch_weather', { city: z.string() }, async ({ city }) => ({
|
|
303
|
+
isError: true,
|
|
304
|
+
content: [{ type: 'text', text: `couldn't reach the weather service for ${city}` }],
|
|
305
|
+
}));
|
|
306
|
+
```
|
|
307
|
+
|
|
308
|
+
opentel-mcp treats both failure shapes the same way — span marked
|
|
244
309
|
`ERROR`, nothing thrown, the result returned to the caller unchanged:
|
|
245
310
|
|
|
246
311
|
```
|
|
@@ -273,11 +338,17 @@ success/failure through span status, not an attribute. Source of truth:
|
|
|
273
338
|
|
|
274
339
|
### Metrics
|
|
275
340
|
|
|
276
|
-
|
|
277
|
-
API-only pattern as tracing
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
`
|
|
341
|
+
The four base `mcp.tool.*` call/duration metrics below, via
|
|
342
|
+
`@opentelemetry/api`'s Metrics API — same API-only pattern as tracing
|
|
343
|
+
(see "Two modes" below): nothing is recorded until a `MeterProvider` is
|
|
344
|
+
registered — **except under `setupNodeSdk: true`, which registers one for
|
|
345
|
+
you (v0.15.0+); see "Dev-mode metrics" below.** Set `enableMetrics: false`
|
|
346
|
+
to opt out even when one is (dev-mode included); tracing is unaffected
|
|
347
|
+
either way. Source of truth: `src/metrics.js`. Twelve metrics exist
|
|
348
|
+
across the library in total — this section's four, plus two cost/token
|
|
349
|
+
metrics ("Cost & Token Attribution" below), five Agent Thrash Detection
|
|
350
|
+
metrics ("Agent Thrash Detection" below), and one schema-drift metric
|
|
351
|
+
("Tool schema drift detection" below).
|
|
281
352
|
|
|
282
353
|
| Metric | Type | Unit | Attributes | Emitted when |
|
|
283
354
|
|---|---|---|---|---|
|
|
@@ -332,12 +403,61 @@ instrumentMcpServer(server, {});
|
|
|
332
403
|
|
|
333
404
|
`http://localhost:4318` is SigNoz's default local OTLP/HTTP (OpenTelemetry
|
|
334
405
|
Protocol — the wire format traces/metrics travel over) endpoint; point it
|
|
335
|
-
at your own collector in production. `@opentelemetry/
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
true` dev path
|
|
406
|
+
at your own collector in production. `@opentelemetry/exporter-metrics-otlp-http`
|
|
407
|
+
is a host-app dependency — opentel-mcp doesn't bundle it. `@opentelemetry/sdk-metrics`,
|
|
408
|
+
`@opentelemetry/sdk-trace-node`, and `@opentelemetry/exporter-trace-otlp-http`
|
|
409
|
+
are already runtime dependencies of opentel-mcp itself (`@opentelemetry/sdk-metrics`
|
|
410
|
+
as of v0.15.0, for the dev-mode metrics path below; the other two for
|
|
411
|
+
`setupNodeSdk: true`'s dev tracing path, same as before), so no extra
|
|
412
|
+
install is needed for those three — only the OTLP metrics exporter above
|
|
413
|
+
needs installing yourself.
|
|
414
|
+
|
|
415
|
+
#### Dev-mode metrics (v0.15.0+)
|
|
416
|
+
|
|
417
|
+
`setupNodeSdk: true` now also stands up an owned `MeterProvider` — the
|
|
418
|
+
same dev-friendly, zero-config posture tracing has always had under this
|
|
419
|
+
flag, extended to metrics (ADR 023, `docs/adr/023-dev-mode-metrics.md`).
|
|
420
|
+
This is a dev-mode-only exception to the API-only framing above, the same
|
|
421
|
+
way `StderrSpanExporter` (ADR 003) is a dev-mode-only exception for
|
|
422
|
+
tracing — `setupNodeSdk: false` (the default, and the production posture)
|
|
423
|
+
is completely unaffected; metrics stay exactly as API-only as they are
|
|
424
|
+
today.
|
|
425
|
+
|
|
426
|
+
What it does, under `setupNodeSdk: true` and `enableMetrics` (default
|
|
427
|
+
`true`, same flag that already opts out of metrics generally):
|
|
428
|
+
|
|
429
|
+
- Builds a `MeterProvider` with a `PeriodicExportingMetricReader` exporting
|
|
430
|
+
to stderr every 5 seconds (not the SDK's own 60-second default — a
|
|
431
|
+
quickstart user making a few tool calls should see output in a few
|
|
432
|
+
seconds, not up to a minute later), via a small custom exporter
|
|
433
|
+
(`src/exporters/stderr-metrics.js`) — same "stderr, never stdout"
|
|
434
|
+
reasoning as `StderrSpanExporter` (ADR 003): a `StdioServerTransport`
|
|
435
|
+
server's JSON-RPC stream lives on stdout, so diagnostic output of any
|
|
436
|
+
kind must go to stderr instead.
|
|
437
|
+
- Prints one line per instrument × attribute-set, not a JSON dump:
|
|
438
|
+
counters as `name{attrs} = <value>`, histograms as
|
|
439
|
+
`name{attrs} count=<n> avg=<sum/count><unit>` — see the quickstart's
|
|
440
|
+
"See it working" section above for a real example.
|
|
441
|
+
- Shares the exact same `Resource` (service.name, and the pricing-staleness
|
|
442
|
+
attribute when applicable) the dev `TracerProvider` already builds, so
|
|
443
|
+
the two signals can never carry different `service.name` values in dev
|
|
444
|
+
mode.
|
|
445
|
+
- **If a `MeterProvider` is already registered globally** — your own, or
|
|
446
|
+
anything else's — this is a complete no-op: nothing is overridden, no
|
|
447
|
+
exception is thrown, and a single `diag.warn` (once per process) says
|
|
448
|
+
so. `setupMeter()` immediately afterward continues to use whatever was
|
|
449
|
+
already registered, exactly as it does today.
|
|
450
|
+
- Extends `server.shutdown()` (already attached under `setupNodeSdk: true`)
|
|
451
|
+
to also flush and shut down the `MeterProvider` it registered, alongside
|
|
452
|
+
the existing `TracerProvider` shutdown — call it during your process's
|
|
453
|
+
shutdown sequence, same as before, to avoid losing buffered metric data.
|
|
454
|
+
|
|
455
|
+
`@opentelemetry/sdk-metrics` is a real (non-dev) dependency of opentel-mcp
|
|
456
|
+
as of this feature — not an optional peer, not a dynamic import with a
|
|
457
|
+
fallback path. See ADR 023's "Dependency strategy" section for the full
|
|
458
|
+
reasoning: its own transitive dependencies were already in this package's
|
|
459
|
+
tree via `@opentelemetry/resources`/`@opentelemetry/sdk-trace-node`, so
|
|
460
|
+
this adds no new package beyond itself.
|
|
341
461
|
|
|
342
462
|
#### Using the Prometheus exporter instead of OTLP — `service.name` needs an extra option
|
|
343
463
|
|
|
@@ -2621,7 +2741,7 @@ pragmatic choice rather than a spec-pure one.
|
|
|
2621
2741
|
revision 2026-07-28, v0.10.0+; see "MCP v2 support" above for what's
|
|
2622
2742
|
covered and `docs/known-gaps.md` entries 6 and 8 for what isn't yet)
|
|
2623
2743
|
- @opentelemetry/api ^1.9.0
|
|
2624
|
-
-
|
|
2744
|
+
- 1017 tests, 1013 passing + 4 intentionally skipped (`npm test`) — see `test/`
|
|
2625
2745
|
- `npm run typecheck` (`tsc --noEmit`) type-checks the public `.d.ts`
|
|
2626
2746
|
surface (`src/index.d.ts` and friends) — see CONTRIBUTING.md
|
|
2627
2747
|
|
|
@@ -2701,6 +2821,17 @@ pragmatic choice rather than a spec-pure one.
|
|
|
2701
2821
|
narrower thing still open, tracked in `docs/known-gaps.md` entry 6:
|
|
2702
2822
|
`thrashSessionState`'s session-awareness memory isn't shared across v2's
|
|
2703
2823
|
per-request calls yet, only the fallback id itself.
|
|
2824
|
+
- v0.15.0: "Make it visible" ✓ — a recent audit found that this library's
|
|
2825
|
+
real depth (12 metrics, two-SDK support, a working local dashboard) was
|
|
2826
|
+
mostly invisible to a first-time user. Two fixes, no instrumentation
|
|
2827
|
+
changes: publishing `opentel-mcp-ui` (ADR 022,
|
|
2828
|
+
`docs/adr/022-publish-ui.md`) — previously a working but unpublished,
|
|
2829
|
+
`"private": true` scaffold, now `npx opentel-mcp-ui --demo`-able from a
|
|
2830
|
+
clean machine — and dev-mode metrics under `setupNodeSdk: true` (ADR
|
|
2831
|
+
023, `docs/adr/023-dev-mode-metrics.md`, see "Dev-mode metrics" above),
|
|
2832
|
+
closing the gap where the documented 30-second quickstart emitted
|
|
2833
|
+
stderr spans but none of the 12 `mcp.tool.*`/`mcp.tool.loop.*`/
|
|
2834
|
+
`mcp.tool.schema_drift.*` metrics it leads with.
|
|
2704
2835
|
- Future: failure clustering + regression detection; recovery hints;
|
|
2705
2836
|
root-cause chaining across parent spans; alignment with the OTel GenAI
|
|
2706
2837
|
SIG's MCP semantic conventions when published
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "opentel-mcp",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.15.0",
|
|
4
4
|
"description": "One-line OpenTelemetry instrumentation for Model Context Protocol (MCP) servers",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.js",
|
|
@@ -69,13 +69,13 @@
|
|
|
69
69
|
"dependencies": {
|
|
70
70
|
"@opentelemetry/exporter-trace-otlp-http": "^0.220.0",
|
|
71
71
|
"@opentelemetry/resources": "^2.9.0",
|
|
72
|
+
"@opentelemetry/sdk-metrics": "^2.9.0",
|
|
72
73
|
"@opentelemetry/sdk-trace-node": "^2.9.0"
|
|
73
74
|
},
|
|
74
75
|
"devDependencies": {
|
|
75
76
|
"@modelcontextprotocol/server": "^2.0.0",
|
|
76
77
|
"@opentelemetry/api": "^1.9.0",
|
|
77
78
|
"@opentelemetry/context-async-hooks": "^2.9.0",
|
|
78
|
-
"@opentelemetry/sdk-metrics": "^2.9.0",
|
|
79
79
|
"@opentelemetry/sdk-trace-base": "^2.9.0",
|
|
80
80
|
"@vitest/coverage-v8": "^2.1.9",
|
|
81
81
|
"js-yaml": "^4.3.1",
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module exporters/stderr-metrics
|
|
3
|
+
* A PushMetricExporter that prints metrics to stderr, in a compact,
|
|
4
|
+
* one-line-per-instrument format — not @opentelemetry/sdk-metrics' own
|
|
5
|
+
* ConsoleMetricExporter, which writes via console.dir (stdout) and dumps
|
|
6
|
+
* each instrument's full internal {descriptor, dataPointType, dataPoints}
|
|
7
|
+
* shape as unflattened JSON (ADR 023).
|
|
8
|
+
*
|
|
9
|
+
* Sibling to ./stderr.js (StderrSpanExporter, ADR 003) — same
|
|
10
|
+
* stdio-transport-safety reasoning: stdout is reserved for a
|
|
11
|
+
* StdioServerTransport server's JSON-RPC stream, so diagnostic output of
|
|
12
|
+
* any OTel signal must go to stderr instead.
|
|
13
|
+
*
|
|
14
|
+
* Reads only the attributes the existing 12 instruments — mcp.tool.*
|
|
15
|
+
* (src/metrics.js), mcp.tool.loop.* (src/thrash/emitter.js), and
|
|
16
|
+
* mcp.tool.schema_drift.* (src/schema-drift/emitter.js) — already attach.
|
|
17
|
+
* Adds no new attribute or metric-label surface of its own, so
|
|
18
|
+
* METRIC_SAFE_ATTRIBUTES governance is unaffected.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
// DataPointType.HISTOGRAM === 0, .SUM === 3 (@opentelemetry/sdk-metrics) —
|
|
22
|
+
// inlined rather than importing the enum, same "avoid a dependency for a
|
|
23
|
+
// couple of integer constants" call stderr.js already makes for
|
|
24
|
+
// ExportResultCode (see that module's own comment).
|
|
25
|
+
const DATA_POINT_TYPE_HISTOGRAM = 0;
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Flattens an attribute bag into a stable, sorted-by-key `{k=v,k=v}`
|
|
29
|
+
* suffix — deterministic across runs (never insertion-order-dependent),
|
|
30
|
+
* so a human diffing two terminal sessions sees only real differences.
|
|
31
|
+
*
|
|
32
|
+
* @param {Record<string, unknown>} attributes
|
|
33
|
+
* @returns {string}
|
|
34
|
+
*/
|
|
35
|
+
function formatAttributes(attributes) {
|
|
36
|
+
const keys = Object.keys(attributes).sort();
|
|
37
|
+
if (keys.length === 0) return '';
|
|
38
|
+
return `{${keys.map((key) => `${key}=${attributes[key]}`).join(',')}}`;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* One entry per data point: a stable identity `key` (which time series
|
|
43
|
+
* this is — the instrument name plus its exact attribute set) separate
|
|
44
|
+
* from `line` (the human-readable text) and `signature` (just the
|
|
45
|
+
* value-bearing part, cheap to compare across export intervals without
|
|
46
|
+
* re-parsing `line`). Counters/gauges/up-down-counters print their
|
|
47
|
+
* current value (`= <n>`); histograms print a count + average
|
|
48
|
+
* (`count=<n> avg=<sum/count><unit>`) rather than a full bucket dump — a
|
|
49
|
+
* terminal reader wants "how many, roughly how long," not a distribution.
|
|
50
|
+
*
|
|
51
|
+
* @param {import('@opentelemetry/sdk-metrics').MetricData} metric
|
|
52
|
+
* @returns {Array<{ key: string, line: string, signature: string }>}
|
|
53
|
+
*/
|
|
54
|
+
function formatMetric(metric) {
|
|
55
|
+
const { name, unit } = metric.descriptor;
|
|
56
|
+
const entries = [];
|
|
57
|
+
|
|
58
|
+
for (const dataPoint of metric.dataPoints) {
|
|
59
|
+
const attrs = formatAttributes(dataPoint.attributes);
|
|
60
|
+
const key = `${name}${attrs}`;
|
|
61
|
+
|
|
62
|
+
if (metric.dataPointType === DATA_POINT_TYPE_HISTOGRAM) {
|
|
63
|
+
const { count, sum } = dataPoint.value;
|
|
64
|
+
const avg = count > 0 && typeof sum === 'number' ? (sum / count).toFixed(1) : '0';
|
|
65
|
+
entries.push({
|
|
66
|
+
key,
|
|
67
|
+
line: `[opentel-mcp metrics] ${key} count=${count} avg=${avg}${unit}`,
|
|
68
|
+
signature: `${count}:${sum}`,
|
|
69
|
+
});
|
|
70
|
+
} else {
|
|
71
|
+
entries.push({
|
|
72
|
+
key,
|
|
73
|
+
line: `[opentel-mcp metrics] ${key} = ${dataPoint.value}`,
|
|
74
|
+
signature: String(dataPoint.value),
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return entries;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export class StderrMetricExporter {
|
|
83
|
+
constructor() {
|
|
84
|
+
// Every mcp.tool.* instrument uses CUMULATIVE aggregation temporality
|
|
85
|
+
// (the SDK's own default — never overridden here): once a tool is
|
|
86
|
+
// called even once, that (instrument, attribute-set) pair gets a
|
|
87
|
+
// permanent data point that every FUTURE export() call re-receives,
|
|
88
|
+
// unchanged, for the rest of the process's life, regardless of
|
|
89
|
+
// whether any new activity happened in the interval between. Without
|
|
90
|
+
// this map, a single call early in a long dev session reprints two
|
|
91
|
+
// identical lines every 5 seconds forever — confirmed empirically,
|
|
92
|
+
// not assumed. Keyed by formatMetric()'s own `key` (instrument name +
|
|
93
|
+
// exact attribute set); values are the last-printed `signature`, so a
|
|
94
|
+
// repeat export() with no real change is detected and skipped without
|
|
95
|
+
// re-deriving or re-parsing the full line.
|
|
96
|
+
this._lastSignatures = new Map();
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* @param {import('@opentelemetry/sdk-metrics').ResourceMetrics} metrics
|
|
101
|
+
* @param {(result: { code: number }) => void} resultCallback
|
|
102
|
+
*/
|
|
103
|
+
export(metrics, resultCallback) {
|
|
104
|
+
try {
|
|
105
|
+
for (const scopeMetrics of metrics.scopeMetrics) {
|
|
106
|
+
for (const metric of scopeMetrics.metrics) {
|
|
107
|
+
// Never emit a line for a metric with zero data points yet (no
|
|
108
|
+
// calls recorded ever) — a dev terminal showing twelve "= 0"
|
|
109
|
+
// lines every 5 seconds before any tool has even been called
|
|
110
|
+
// is noise, not signal. formatMetric() already produces zero
|
|
111
|
+
// entries for such a metric, so this loop is naturally a no-op
|
|
112
|
+
// for it without any extra check here.
|
|
113
|
+
for (const { key, line, signature } of formatMetric(metric)) {
|
|
114
|
+
if (this._lastSignatures.get(key) === signature) continue;
|
|
115
|
+
console.error(line);
|
|
116
|
+
// Set only after a successful print, not before — if
|
|
117
|
+
// console.error somehow throws, this export() call fails
|
|
118
|
+
// (caught below, reported via resultCallback({ code: 1 })),
|
|
119
|
+
// and an SDK-level retry should see this as still unprinted
|
|
120
|
+
// rather than silently marked done.
|
|
121
|
+
this._lastSignatures.set(key, signature);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
// 0 === ExportResultCode.SUCCESS (@opentelemetry/core) — see
|
|
126
|
+
// stderr.js's identical comment for why this is inlined.
|
|
127
|
+
resultCallback({ code: 0 });
|
|
128
|
+
} catch {
|
|
129
|
+
// Never-throw: a malformed metric data point must degrade to a
|
|
130
|
+
// failed export, never crash the host process the metric came from.
|
|
131
|
+
resultCallback({ code: 1 });
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
forceFlush() {
|
|
136
|
+
return Promise.resolve();
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
shutdown() {
|
|
140
|
+
return Promise.resolve();
|
|
141
|
+
}
|
|
142
|
+
}
|
package/src/index.d.ts
CHANGED
|
@@ -44,6 +44,12 @@ export interface InstrumentOptions {
|
|
|
44
44
|
* for opting out of metrics even when a `MeterProvider` **is**
|
|
45
45
|
* registered, not a substitute for that default.
|
|
46
46
|
*
|
|
47
|
+
* Also gates `setupNodeSdk`'s dev-mode `MeterProvider` (ADR 023,
|
|
48
|
+
* `docs/adr/023-dev-mode-metrics.md`, v0.15.0+) — `{ setupNodeSdk: true,
|
|
49
|
+
* enableMetrics: false }` gets dev-mode traces with no dev-mode metrics
|
|
50
|
+
* at all, the same combination this flag already produces against a
|
|
51
|
+
* host-registered `MeterProvider`.
|
|
52
|
+
*
|
|
47
53
|
* @default true
|
|
48
54
|
*/
|
|
49
55
|
enableMetrics?: boolean;
|
|
@@ -53,11 +59,28 @@ export interface InstrumentOptions {
|
|
|
53
59
|
* `NodeTracerProvider` (always exporting to stderr; additionally to
|
|
54
60
|
* `exporterUrl` via OTLP/HTTP if set).
|
|
55
61
|
*
|
|
62
|
+
* **Also stands up a dev-mode `MeterProvider` (ADR 023,
|
|
63
|
+
* `docs/adr/023-dev-mode-metrics.md`, v0.15.0+)**, gated additionally by
|
|
64
|
+
* `enableMetrics` (above, default `true`): a `PeriodicExportingMetricReader`
|
|
65
|
+
* exporting all 12 `mcp.tool.*`/`mcp.tool.loop.*`/`mcp.tool.schema_drift.*`
|
|
66
|
+
* instruments to stderr every 5 seconds, in a compact
|
|
67
|
+
* `name{attrs} = value` format — never `@opentelemetry/sdk-metrics`'
|
|
68
|
+
* own `ConsoleMetricExporter`, which writes to stdout and would corrupt
|
|
69
|
+
* a `StdioServerTransport` server's JSON-RPC stream (the same ADR 003
|
|
70
|
+
* reasoning the dev tracer's stderr-only exporter already follows). If
|
|
71
|
+
* a `MeterProvider` is already registered globally when this runs, this
|
|
72
|
+
* is a complete no-op — nothing is overridden, no exception is thrown,
|
|
73
|
+
* and a single `diag.warn` (once per process) names the situation;
|
|
74
|
+
* metrics continue to flow through whatever was already registered,
|
|
75
|
+
* exactly as they would with this flag `false`.
|
|
76
|
+
*
|
|
56
77
|
* When `false` (the default), spans are emitted via whatever OpenTelemetry
|
|
57
78
|
* `TracerProvider` the host application has already registered globally —
|
|
58
79
|
* or dropped silently if none has been registered. This default keeps
|
|
59
80
|
* {@link instrumentMcpServer} from ever overriding a host application's own
|
|
60
|
-
* OpenTelemetry setup.
|
|
81
|
+
* OpenTelemetry setup. Metrics behavior is completely unaffected by this
|
|
82
|
+
* flag being `false` — API-only, exactly as documented in the README's
|
|
83
|
+
* "Metrics" section, with no dev-mode exception.
|
|
61
84
|
*
|
|
62
85
|
* @default false
|
|
63
86
|
*/
|
|
@@ -326,9 +349,14 @@ export type DuckTypedMcpServer = {
|
|
|
326
349
|
* `options.setupNodeSdk` is `true`, the returned object also gets a
|
|
327
350
|
* `shutdown()` method that flushes and shuts down the `NodeTracerProvider`
|
|
328
351
|
* created for it — call it during your process's own shutdown sequence to
|
|
329
|
-
* avoid losing buffered spans.
|
|
330
|
-
*
|
|
331
|
-
*
|
|
352
|
+
* avoid losing buffered spans. As of v0.15.0 (ADR 023), when dev-mode
|
|
353
|
+
* metrics were also set up (`enableMetrics` not `false`, and no
|
|
354
|
+
* `MeterProvider` was already registered globally — see `setupNodeSdk`'s
|
|
355
|
+
* own doc above), the same `shutdown()` call also flushes and shuts down
|
|
356
|
+
* that `MeterProvider`, so one call covers both signals. `shutdown` is
|
|
357
|
+
* typed as optional because it is only attached at runtime when
|
|
358
|
+
* `setupNodeSdk` is `true`; check for its presence before calling. The
|
|
359
|
+
* returned object also gets a
|
|
332
360
|
* `getThrashSummary()` method (v0.6.0) returning a point-in-time,
|
|
333
361
|
* in-process {@link ThrashSummary} — no OTel involved, nothing sent
|
|
334
362
|
* anywhere; see the README's "Agent Thrash Detection" section. Unlike
|
package/src/instrument.js
CHANGED
|
@@ -4,13 +4,15 @@
|
|
|
4
4
|
|
|
5
5
|
import { createRequire } from 'node:module';
|
|
6
6
|
import { randomUUID } from 'node:crypto';
|
|
7
|
-
import { trace, diag, SpanStatusCode, SpanKind } from '@opentelemetry/api';
|
|
7
|
+
import { trace, metrics, diag, SpanStatusCode, SpanKind } from '@opentelemetry/api';
|
|
8
8
|
import { getV1Sdk, getV2Sdk } from './sdk/detect.js';
|
|
9
9
|
import { NodeTracerProvider, SimpleSpanProcessor, BatchSpanProcessor } from '@opentelemetry/sdk-trace-node';
|
|
10
|
+
import { MeterProvider, PeriodicExportingMetricReader } from '@opentelemetry/sdk-metrics';
|
|
10
11
|
import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-http';
|
|
11
12
|
import { resourceFromAttributes } from '@opentelemetry/resources';
|
|
12
13
|
import { resolveOptions } from './config.js';
|
|
13
14
|
import { StderrSpanExporter } from './exporters/stderr.js';
|
|
15
|
+
import { StderrMetricExporter } from './exporters/stderr-metrics.js';
|
|
14
16
|
import { setupMeter } from './metrics.js';
|
|
15
17
|
import { computeFingerprint, MAX_ERROR_CLASS_LENGTH } from './fingerprint/compose.js';
|
|
16
18
|
import { toSpanAttributes, ATTRIBUTE_KEYS } from './fingerprint/attributes.js';
|
|
@@ -697,9 +699,31 @@ function assertInstrumentFirst(server, resolved) {
|
|
|
697
699
|
}
|
|
698
700
|
}
|
|
699
701
|
|
|
702
|
+
// ADR 023 (v0.15.0): dev-mode metrics' export interval under
|
|
703
|
+
// setupNodeSdk: true — 5s, not @opentelemetry/sdk-metrics'
|
|
704
|
+
// PeriodicExportingMetricReader's own 60s default. A quickstart user
|
|
705
|
+
// making a few tool calls and watching their terminal should see output
|
|
706
|
+
// within a few seconds, not up to a minute later. Not currently exposed
|
|
707
|
+
// as a configurable option — same "no configurable knobs" posture
|
|
708
|
+
// StderrSpanExporter already takes.
|
|
709
|
+
const DEV_METRICS_EXPORT_INTERVAL_MS = 5000;
|
|
710
|
+
|
|
711
|
+
// Guards the "a MeterProvider is already registered, dev metrics skipped"
|
|
712
|
+
// diagnostic below (ADR 023) so it fires once per process, same
|
|
713
|
+
// once-per-process pattern config.js's warnedServiceNameIgnored/
|
|
714
|
+
// warnedPricingStale already establish.
|
|
715
|
+
let warnedMeterProviderAlreadyRegistered = false;
|
|
716
|
+
|
|
717
|
+
// Test-only, same purpose as config.js's __resetServiceNameWarnedForTests/
|
|
718
|
+
// __resetPricingStaleWarnedForTests. Not part of the public API.
|
|
719
|
+
export function __resetMeterProviderWarnedForTests() {
|
|
720
|
+
warnedMeterProviderAlreadyRegistered = false;
|
|
721
|
+
}
|
|
722
|
+
|
|
700
723
|
/**
|
|
701
724
|
* Resolves the Tracer to use for this server, optionally standing up an
|
|
702
|
-
* owned NodeTracerProvider
|
|
725
|
+
* owned NodeTracerProvider (and, per ADR 023, an owned dev-mode
|
|
726
|
+
* MeterProvider) first.
|
|
703
727
|
*
|
|
704
728
|
* @param {import('@modelcontextprotocol/sdk/server/index.js').Server} server
|
|
705
729
|
* @param {Required<import('./config.js').InstrumentOptions>} resolved
|
|
@@ -724,13 +748,73 @@ function setupTracer(server, resolved) {
|
|
|
724
748
|
? { [ATTR_MCP_PRICING_DEFAULT_TABLE_LAST_VERIFIED]: DEFAULT_PRICING_LAST_VERIFIED }
|
|
725
749
|
: {};
|
|
726
750
|
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
751
|
+
// ADR 023: shared between the dev TracerProvider below and the dev
|
|
752
|
+
// MeterProvider further down, so service.name (and the pricing
|
|
753
|
+
// resource attribute) can never drift between the two signals in dev
|
|
754
|
+
// mode — one Resource construction, not two independently-built ones.
|
|
755
|
+
const resource = resourceFromAttributes({ 'service.name': resolved.serviceName, ...pricingResourceAttributes });
|
|
756
|
+
|
|
757
|
+
const provider = new NodeTracerProvider({ resource, spanProcessors });
|
|
731
758
|
provider.register();
|
|
732
759
|
|
|
733
|
-
|
|
760
|
+
// ADR 023 (v0.15.0): dev-mode metrics. Only attempted when metrics
|
|
761
|
+
// aren't separately opted out via enableMetrics: false — a host who's
|
|
762
|
+
// already said "no metrics from this library" via that flag gets
|
|
763
|
+
// exactly that, with no new interaction between the two options to
|
|
764
|
+
// reason about.
|
|
765
|
+
let meterProvider = null;
|
|
766
|
+
if (resolved.enableMetrics) {
|
|
767
|
+
const candidateMeterProvider = new MeterProvider({
|
|
768
|
+
resource,
|
|
769
|
+
readers: [
|
|
770
|
+
new PeriodicExportingMetricReader({
|
|
771
|
+
exporter: new StderrMetricExporter(),
|
|
772
|
+
exportIntervalMillis: DEV_METRICS_EXPORT_INTERVAL_MS,
|
|
773
|
+
}),
|
|
774
|
+
],
|
|
775
|
+
});
|
|
776
|
+
|
|
777
|
+
// metrics.setGlobalMeterProvider() returns false (documented,
|
|
778
|
+
// public .d.ts return type — not an internal detail) when a
|
|
779
|
+
// MeterProvider is already globally registered. In that case the
|
|
780
|
+
// ALREADY-registered provider — whoever's it is — keeps being what
|
|
781
|
+
// setupMeter() resolves to immediately below; candidateMeterProvider
|
|
782
|
+
// is never used for anything and must be shut down immediately
|
|
783
|
+
// rather than merely left unreferenced: @opentelemetry/sdk-metrics'
|
|
784
|
+
// MeterProvider constructor already called
|
|
785
|
+
// PeriodicExportingMetricReader's onInitialized() (confirmed by
|
|
786
|
+
// reading its source) — i.e. its setInterval is already running
|
|
787
|
+
// from the moment `new MeterProvider(...)` above returned, BEFORE
|
|
788
|
+
// this registration attempt — so an un-shut-down loser here would
|
|
789
|
+
// tick forever, every DEV_METRICS_EXPORT_INTERVAL_MS, exporting an
|
|
790
|
+
// empty ResourceMetrics (nothing ever creates an instrument on an
|
|
791
|
+
// abandoned provider) to a StderrMetricExporter nobody reads.
|
|
792
|
+
// Fire-and-forget, never awaited: shutdown must not block this
|
|
793
|
+
// synchronous setup path, and a rejection here must never throw
|
|
794
|
+
// past it (never-throw) — this is purely cleanup of a provider
|
|
795
|
+
// nothing else will ever reference again.
|
|
796
|
+
const registered = metrics.setGlobalMeterProvider(candidateMeterProvider);
|
|
797
|
+
if (registered) {
|
|
798
|
+
meterProvider = candidateMeterProvider;
|
|
799
|
+
} else {
|
|
800
|
+
candidateMeterProvider.shutdown().catch(() => {});
|
|
801
|
+
if (!warnedMeterProviderAlreadyRegistered) {
|
|
802
|
+
warnedMeterProviderAlreadyRegistered = true;
|
|
803
|
+
diag.warn(
|
|
804
|
+
'opentel-mcp: setupNodeSdk is true, but a MeterProvider is already registered globally — ' +
|
|
805
|
+
'dev-mode stderr metrics (ADR 023) were not set up, and the existing MeterProvider was left ' +
|
|
806
|
+
'untouched. This mcp.tool.* metrics will still be recorded through it as normal; only the ' +
|
|
807
|
+
"convenience stderr printout is skipped. Remove whatever registered a MeterProvider before " +
|
|
808
|
+
'instrumentMcpServer() ran if you intended to use the dev-mode printout instead.',
|
|
809
|
+
);
|
|
810
|
+
}
|
|
811
|
+
}
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
server.shutdown = async () => {
|
|
815
|
+
await provider.shutdown();
|
|
816
|
+
if (meterProvider) await meterProvider.shutdown();
|
|
817
|
+
};
|
|
734
818
|
}
|
|
735
819
|
|
|
736
820
|
// Always the final step: when setupNodeSdk is false, this picks up
|