opentel-mcp 0.14.0 → 0.16.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 +187 -0
- package/README.md +225 -16
- package/package.json +4 -3
- package/src/attributes.js +23 -0
- package/src/config.js +75 -0
- package/src/exporters/stderr-metrics.js +142 -0
- package/src/flush-on-exit.js +215 -0
- package/src/index.d.ts +95 -4
- package/src/instrument.js +333 -7
- package/src/metrics.js +15 -0
- package/src/sdk/detect.js +9 -2
- package/src/unactionable.js +129 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,192 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.16.0
|
|
4
|
+
|
|
5
|
+
Finishes what 0.15 started. Telemetry now survives the way MCP clients
|
|
6
|
+
actually stop servers (`flushOnExit`), and two new signals land: errors
|
|
7
|
+
your agent can't act on, and opt-in resources/prompts coverage. Plus a fix
|
|
8
|
+
for SDK v2 servers that declare `capabilities.tools`.
|
|
9
|
+
`setupNodeSdk: false` (production) behavior is unchanged apart from the
|
|
10
|
+
new span attributes on `isError: true` results and the v2 fix; resources/
|
|
11
|
+
prompts coverage is off unless you opt in. No new metric label keys.
|
|
12
|
+
|
|
13
|
+
### Added
|
|
14
|
+
|
|
15
|
+
- **`flushOnExit` (ADR 024, `docs/adr/024-flush-on-exit.md`).** Under
|
|
16
|
+
`setupNodeSdk: true`, buffered spans and dev-mode metrics are now
|
|
17
|
+
flushed automatically when the process stops: on `beforeExit` (stdin
|
|
18
|
+
closed, loop drained), `SIGTERM` and `SIGINT`. MCP clients built on the
|
|
19
|
+
SDK's stdio transport stop servers with exactly that sequence (stdin
|
|
20
|
+
close → SIGTERM → SIGKILL, 2 s apart); the old `process.on('beforeExit')`
|
|
21
|
+
snippet only covered the first step. On by default with
|
|
22
|
+
`setupNodeSdk: true`; `flushOnExit: false` opts out;
|
|
23
|
+
`flushOnExit: { timeoutMs }` lowers the 1000 ms cap.
|
|
24
|
+
- One listener set per process, however many servers are instrumented,
|
|
25
|
+
and it's removed again once every server has shut down.
|
|
26
|
+
- After flushing, a signal is re-raised when no other listener for it
|
|
27
|
+
exists, so the process still dies by that signal. If your code has its
|
|
28
|
+
own listener, yours decides. `beforeExit` never changes the exit code.
|
|
29
|
+
- **No effect at all with `setupNodeSdk: false`** (production
|
|
30
|
+
behavior unchanged): passing it there logs a one-time `diag.warn`.
|
|
31
|
+
- Not covered: `process.exit()` (call `await server.shutdown()` first)
|
|
32
|
+
and `SIGKILL`.
|
|
33
|
+
|
|
34
|
+
- **Unactionable tool errors (ADR 025, `docs/adr/025-unactionable-errors.md`).**
|
|
35
|
+
Every `isError: true` span now carries `mcp.failure.unactionable`
|
|
36
|
+
(boolean) and `mcp.failure.content_length_bucket` (`empty` | `tiny` |
|
|
37
|
+
`short` | `medium` | `long`). Unactionable means no content, or under
|
|
38
|
+
10 characters of trimmed text with no image/audio/resource item: an
|
|
39
|
+
error that gives the agent nothing to act on. Computed only from text
|
|
40
|
+
lengths and non-text item counts, never from the text; a canary-string
|
|
41
|
+
test checks every exported span and metric. Span-only (no metric label,
|
|
42
|
+
`METRIC_SAFE_ATTRIBUTES` unchanged); fingerprints and categories
|
|
43
|
+
unchanged. Configure with `unactionableErrors: { enabled, minTextLength }`
|
|
44
|
+
or `OTEL_MCP_UNACTIONABLE_ERRORS_ENABLED` /
|
|
45
|
+
`OTEL_MCP_UNACTIONABLE_ERRORS_MIN_TEXT_LENGTH`.
|
|
46
|
+
|
|
47
|
+
- **Resources and prompts coverage, opt-in (ADR 026,
|
|
48
|
+
`docs/adr/026-resources-prompts-coverage.md`).**
|
|
49
|
+
`coverage: { resources: true, prompts: true }` also traces
|
|
50
|
+
`resources/read`, `resources/list`, `resources/templates/list`,
|
|
51
|
+
`prompts/get` and `prompts/list`, on SDK v1 and v2, low-level `Server`
|
|
52
|
+
and `McpServer`. Both are off by default; nothing changes unless you opt in.
|
|
53
|
+
- Spans are named by method (`prompts/get <name>` for prompts/get) and
|
|
54
|
+
carry `mcp.method.name` (+ `gen_ai.prompt.name` on prompts/get, capped
|
|
55
|
+
at 128 characters). `gen_ai.tool.name` is absent.
|
|
56
|
+
- Never captured: resource URIs, prompt arguments, result content, or
|
|
57
|
+
the exception message of a failing request (SDK resource errors embed
|
|
58
|
+
the URI). Failures get status `ERROR`, `error.type` and the usual
|
|
59
|
+
fingerprint/category/channel.
|
|
60
|
+
- New histogram `mcp.server.operation.duration`, labeled by
|
|
61
|
+
`mcp.method.name` + `error.type` only. No new label keys;
|
|
62
|
+
`mcp.method.name` stays a fixed set of constants (now 7 values).
|
|
63
|
+
- A handler already registered when `instrumentMcpServer()` runs is
|
|
64
|
+
skipped with one `diag.warn`, never a throw. That includes SDK v2
|
|
65
|
+
`McpServer` declaring `capabilities.resources/prompts` in its
|
|
66
|
+
constructor.
|
|
67
|
+
|
|
68
|
+
### Changed
|
|
69
|
+
|
|
70
|
+
- `server.shutdown()` (under `setupNodeSdk: true`) is now idempotent:
|
|
71
|
+
every call returns the same promise, so an explicit `shutdown()` and
|
|
72
|
+
`flushOnExit` never shut the providers down twice.
|
|
73
|
+
- The README quickstart and both examples no longer need the
|
|
74
|
+
`process.on('beforeExit', ...)` block; it's been removed.
|
|
75
|
+
|
|
76
|
+
### Fixed
|
|
77
|
+
|
|
78
|
+
- **SDK v2: a `McpServer` constructed with `capabilities: { tools: {} }`
|
|
79
|
+
can now be instrumented.** v2's `McpServer` installs its own
|
|
80
|
+
`tools/list`/`tools/call` dispatchers in its constructor when that
|
|
81
|
+
capability is declared, so `instrumentMcpServer()` threw
|
|
82
|
+
`INSTRUMENT_FIRST_ERROR` on every such server, whatever the call order.
|
|
83
|
+
It now recognizes McpServer's own constructor-installed handlers (v2
|
|
84
|
+
McpServer, installer ran, no tool registered yet, not yet connected),
|
|
85
|
+
takes them down with the public `removeRequestHandler()`, and lets
|
|
86
|
+
McpServer re-install them through the instrumented `setRequestHandler`,
|
|
87
|
+
so they're wrapped like any lazily-installed handler. Genuine misorders
|
|
88
|
+
still throw: a tool registered before instrumenting (v1 or v2, with or
|
|
89
|
+
without declared capabilities) or a low-level `Server` handler
|
|
90
|
+
registered first. Present since v2 support shipped (SDK 2.0.0).
|
|
91
|
+
|
|
92
|
+
## 0.15.0
|
|
93
|
+
|
|
94
|
+
"Make it visible" — no instrumentation changes, no `tools/call` hot-path
|
|
95
|
+
changes. Phase 1 (ADR 022, `docs/adr/022-publish-ui.md`): publishes
|
|
96
|
+
`opentel-mcp-ui`, previously a working but `"private": true`, unpublished
|
|
97
|
+
scaffold with zero mentions in this README. See
|
|
98
|
+
[`opentel-mcp-ui`'s own CHANGELOG](https://www.npmjs.com/package/opentel-mcp-ui)
|
|
99
|
+
for what changed in that package. Phase 2 (ADR 023,
|
|
100
|
+
`docs/adr/023-dev-mode-metrics.md`): dev-mode metrics under
|
|
101
|
+
`setupNodeSdk: true` — see "Added" below.
|
|
102
|
+
|
|
103
|
+
### Added
|
|
104
|
+
|
|
105
|
+
- README: a "See it locally" section near the top, pointing at
|
|
106
|
+
`npx opentel-mcp-ui --demo` — this library had no documented way for a
|
|
107
|
+
new reader to see its own output before installing anything.
|
|
108
|
+
- `setupNodeSdk: true` now also stands up an owned `MeterProvider` (ADR
|
|
109
|
+
023): a `PeriodicExportingMetricReader` exporting all 12 `mcp.tool.*`/
|
|
110
|
+
`mcp.tool.loop.*`/`mcp.tool.schema_drift.*` instruments to stderr every
|
|
111
|
+
5 seconds, via a new `StderrMetricExporter` (compact
|
|
112
|
+
`name{attrs} = value` lines, never a stdout-writing JSON dump — same
|
|
113
|
+
ADR 003 reasoning `StderrSpanExporter` already follows). Gated
|
|
114
|
+
additionally by `enableMetrics` (default `true`, unchanged option).
|
|
115
|
+
Closes the gap where the documented 30-second quickstart emitted
|
|
116
|
+
stderr spans but none of its 12 metrics. `setupNodeSdk: false` (the
|
|
117
|
+
default, and the production posture) is completely unaffected — every
|
|
118
|
+
new code path is gated behind the same existing `setupNodeSdk === true`
|
|
119
|
+
check `StderrSpanExporter` already uses.
|
|
120
|
+
- If a `MeterProvider` is already registered globally when this runs
|
|
121
|
+
(your own, or anything else's), this is a complete no-op: nothing is
|
|
122
|
+
overridden, no exception is thrown, and a single `diag.warn` (once
|
|
123
|
+
per process, not once per `instrumentMcpServer()` call) names the
|
|
124
|
+
situation. Detected via `@opentelemetry/api`'s own documented
|
|
125
|
+
`boolean` return from `setGlobalMeterProvider()` — not by inspecting
|
|
126
|
+
internals. The abandoned candidate provider built before that check
|
|
127
|
+
is shut down immediately (fire-and-forget), closing a leak where its
|
|
128
|
+
own periodic reader would otherwise tick forever, unused, in the
|
|
129
|
+
background.
|
|
130
|
+
- `server.shutdown()` (already attached under `setupNodeSdk: true`) now
|
|
131
|
+
also flushes and shuts down this `MeterProvider`, alongside the
|
|
132
|
+
existing `TracerProvider` shutdown it already performed.
|
|
133
|
+
- `StderrMetricExporter` only reprints a metric when its value has
|
|
134
|
+
actually changed since the last export. Every instrument here is
|
|
135
|
+
CUMULATIVE, so without this a single early tool call would otherwise
|
|
136
|
+
reprint two unchanged lines every 5 seconds for the rest of the
|
|
137
|
+
process's life — confirmed empirically before this fix shipped, not
|
|
138
|
+
assumed.
|
|
139
|
+
|
|
140
|
+
### Changed
|
|
141
|
+
|
|
142
|
+
- **`@opentelemetry/sdk-metrics` is now a runtime (`dependencies`, not
|
|
143
|
+
`devDependencies`) dependency of `opentel-mcp`, installed for every
|
|
144
|
+
user — including under `setupNodeSdk: false`, which never executes any
|
|
145
|
+
code from it.** Needed at import time for the feature above; moving it
|
|
146
|
+
to `dependencies` means `npm install opentel-mcp` always pulls it in,
|
|
147
|
+
regardless of whether a given deployment ever sets `setupNodeSdk: true`.
|
|
148
|
+
Adds no new transitive dependency: its own two dependencies
|
|
149
|
+
(`@opentelemetry/core`, `@opentelemetry/resources`) were already in
|
|
150
|
+
this package's tree via `@opentelemetry/sdk-trace-node`/the existing
|
|
151
|
+
`@opentelemetry/resources` dependency. See ADR 023's "Dependency
|
|
152
|
+
strategy" section for the full reasoning, including why an optional-peer
|
|
153
|
+
+ dynamic-import approach was considered and rejected.
|
|
154
|
+
|
|
155
|
+
### Fixed
|
|
156
|
+
|
|
157
|
+
- The example servers (`examples/hello-server`, `examples/hello-mcpserver`)
|
|
158
|
+
never showed a metrics line in their own READMEs' "What you'll see": a
|
|
159
|
+
single piped `tools/call` request exits the process before the 5-second
|
|
160
|
+
export interval. Both now flush once via `process.on('beforeExit', ...)`
|
|
161
|
+
right before the process would otherwise exit — confirmed
|
|
162
|
+
`StdioServerTransport` does not call its own `close()`/`onclose` on
|
|
163
|
+
stdin EOF, so `beforeExit` is the one hook that still allows the async
|
|
164
|
+
flush to complete first.
|
|
165
|
+
- Documentation only, no code/behavior changes:
|
|
166
|
+
- README claimed "Four `mcp.tool.*` metrics" in a way that read as the
|
|
167
|
+
library's total metric count. Corrected: four is this section's own
|
|
168
|
+
base call/duration metrics; twelve exist in total across cost/token
|
|
169
|
+
attribution (2), Agent Thrash Detection (5), and schema drift (1),
|
|
170
|
+
each already documented in their own sections.
|
|
171
|
+
- README's test count was stale at every point it was checked this
|
|
172
|
+
release; now 1017 tests, 1013 passing + 4 intentionally skipped.
|
|
173
|
+
- README's SigNoz metrics-wiring example described
|
|
174
|
+
`@opentelemetry/sdk-metrics` as a host-app dependency opentel-mcp
|
|
175
|
+
"doesn't bundle" — true before this release, no longer true now that
|
|
176
|
+
it's a real dependency (see "Changed" above).
|
|
177
|
+
|
|
178
|
+
### CI
|
|
179
|
+
|
|
180
|
+
- `dependency-audit` (`.github/workflows/ci.yml`) no longer audits this
|
|
181
|
+
monorepo's own shared, hoisted `node_modules` — `npm audit`, even with
|
|
182
|
+
`--omit=dev` and run from inside `packages/core/`, scans the entire
|
|
183
|
+
installed tree regardless of cwd or `--workspace` (confirmed
|
|
184
|
+
empirically), so it was reporting findings from `examples/*`'s real
|
|
185
|
+
`@modelcontextprotocol/sdk` dependency (express/hono/ajv transitives)
|
|
186
|
+
as if they were this library's own. Now packs and installs each of
|
|
187
|
+
`packages/core`/`packages/ui` into its own clean, throwaway project —
|
|
188
|
+
the exact tree a real `npm install` produces — and audits that instead.
|
|
189
|
+
|
|
3
190
|
## 0.14.0
|
|
4
191
|
|
|
5
192
|
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
|
|
@@ -65,12 +94,22 @@ await server.connect(transport);
|
|
|
65
94
|
```
|
|
66
95
|
|
|
67
96
|
That's it. Every tool call now emits a trace. Wire an exporter to see them
|
|
68
|
-
(next section).
|
|
97
|
+
(next section). When the server stops — your MCP client closing stdin, or
|
|
98
|
+
sending SIGTERM/SIGINT — anything still buffered is flushed first,
|
|
99
|
+
automatically (see "Flushing on exit" below).
|
|
69
100
|
|
|
70
101
|
## See it working
|
|
71
102
|
|
|
72
|
-
|
|
73
|
-
|
|
103
|
+
Save the snippet as `server.js`. On its own it just waits: an MCP server
|
|
104
|
+
reads requests from stdin and does nothing until a client sends one. To
|
|
105
|
+
try it without a client, pipe in a single tool call:
|
|
106
|
+
|
|
107
|
+
```bash
|
|
108
|
+
echo '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"echo","arguments":{"text":"hi"}}}' | node server.js
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
The JSON-RPC response goes to stdout, and this prints to stderr — a real,
|
|
112
|
+
captured run (full dump: `examples/hello-mcpserver/README.md`):
|
|
74
113
|
|
|
75
114
|
```
|
|
76
115
|
name: 'tools/call echo'
|
|
@@ -87,6 +126,23 @@ attributes: {
|
|
|
87
126
|
No dashboard needed — `setupNodeSdk: true`'s dev exporter printed this
|
|
88
127
|
directly. Point it at a real backend later; see "Two modes" below.
|
|
89
128
|
|
|
129
|
+
**Metrics print too (v0.15.0+)** — a compact one-line-per-metric summary
|
|
130
|
+
on stderr, reusing the exact `mcp.tool.*` instruments documented in
|
|
131
|
+
"Metrics" below. A long-running server prints it every 5 seconds (not the
|
|
132
|
+
default 60-second export interval). The one-shot piped run above prints it
|
|
133
|
+
once as it exits, because the server flushes when stdin closes (v0.16.0,
|
|
134
|
+
see "Flushing on exit"):
|
|
135
|
+
|
|
136
|
+
```
|
|
137
|
+
[opentel-mcp metrics] mcp.tool.calls{gen_ai.tool.name=echo,mcp.method.name=tools/call} = 1
|
|
138
|
+
[opentel-mcp metrics] mcp.tool.duration{gen_ai.tool.name=echo,mcp.tool.outcome=success} count=1 avg=0.4ms
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
No extra configuration, no `@opentelemetry/sdk-metrics` setup of your
|
|
142
|
+
own — `setupNodeSdk: true` now does for metrics what it's always done for
|
|
143
|
+
traces. See "Dev-mode metrics" below for exactly what this does and
|
|
144
|
+
doesn't change.
|
|
145
|
+
|
|
90
146
|
---
|
|
91
147
|
|
|
92
148
|
The rest of this README goes deeper: both server APIs, every attribute
|
|
@@ -240,7 +296,17 @@ Runnable end-to-end coverage lives in `test/instrument.v2.test.js` and
|
|
|
240
296
|
|
|
241
297
|
An MCP tool can fail two ways: it can throw, or it can return
|
|
242
298
|
`isError: true` on an otherwise-successful response (the case from "The
|
|
243
|
-
problem" above)
|
|
299
|
+
problem" above) — exactly like the quickstart's `echo` tool, but with
|
|
300
|
+
`isError: true` added and no `throw` involved:
|
|
301
|
+
|
|
302
|
+
```js
|
|
303
|
+
server.tool('fetch_weather', { city: z.string() }, async ({ city }) => ({
|
|
304
|
+
isError: true,
|
|
305
|
+
content: [{ type: 'text', text: `couldn't reach the weather service for ${city}` }],
|
|
306
|
+
}));
|
|
307
|
+
```
|
|
308
|
+
|
|
309
|
+
opentel-mcp treats both failure shapes the same way — span marked
|
|
244
310
|
`ERROR`, nothing thrown, the result returned to the caller unchanged:
|
|
245
311
|
|
|
246
312
|
```
|
|
@@ -250,6 +316,60 @@ error.type = tool_error
|
|
|
250
316
|
|
|
251
317
|
Verified in `test/instrument.test.js`'s "tool-level failure" tests.
|
|
252
318
|
|
|
319
|
+
#### Errors your agent can't act on (v0.16.0)
|
|
320
|
+
|
|
321
|
+
An `isError: true` result with no content, or just `"Error"`, tells the
|
|
322
|
+
model nothing about what went wrong or what to do next — a common cause
|
|
323
|
+
of retries and thrash. Every `isError: true` span also carries:
|
|
324
|
+
|
|
325
|
+
| Attribute | Values |
|
|
326
|
+
| --- | --- |
|
|
327
|
+
| `mcp.failure.unactionable` | `true` when there's no content, or fewer than 10 characters of text (whitespace-trimmed) and no image/audio/resource item |
|
|
328
|
+
| `mcp.failure.content_length_bucket` | `empty` (0) · `tiny` (1–9) · `short` (< 80) · `medium` (< 500) · `long` |
|
|
329
|
+
|
|
330
|
+
Both are computed **only from lengths and item counts — never from the
|
|
331
|
+
text itself** (proved by a canary-string test in
|
|
332
|
+
`test/unactionable.test.js`), on spans only, never as metric labels. They
|
|
333
|
+
don't change fingerprints or categories. Configure with
|
|
334
|
+
`unactionableErrors: { enabled, minTextLength }` (defaults `true`, `10`).
|
|
335
|
+
Design: [ADR 025](../../docs/adr/025-unactionable-errors.md).
|
|
336
|
+
|
|
337
|
+
### Resources and prompts (opt-in, v0.16.0)
|
|
338
|
+
|
|
339
|
+
Tools are traced by default. `resources/*` and `prompts/*` requests can be
|
|
340
|
+
traced too, if you turn them on:
|
|
341
|
+
|
|
342
|
+
```js
|
|
343
|
+
instrumentMcpServer(server, {
|
|
344
|
+
coverage: { resources: true, prompts: true }, // either or both
|
|
345
|
+
});
|
|
346
|
+
```
|
|
347
|
+
|
|
348
|
+
| Method | Span name | Attributes |
|
|
349
|
+
| --- | --- | --- |
|
|
350
|
+
| `resources/read`, `resources/list`, `resources/templates/list` | the method, e.g. `resources/read` | `mcp.method.name` |
|
|
351
|
+
| `prompts/get` | `prompts/get <prompt name>` | `mcp.method.name`, `gen_ai.prompt.name` |
|
|
352
|
+
| `prompts/list` | `prompts/list` | `mcp.method.name` |
|
|
353
|
+
|
|
354
|
+
- **Never captured:** resource URIs, prompt arguments, result content. A
|
|
355
|
+
failing request gets status `ERROR`, `error.type`, and the usual
|
|
356
|
+
fingerprint/category/channel (`mcp.failure.*`), but **not** the exception
|
|
357
|
+
message, because SDK resource errors embed the URI in it.
|
|
358
|
+
- `gen_ai.tool.name` is absent (not empty) on these spans, so they never
|
|
359
|
+
show up in per-tool views or tool metrics.
|
|
360
|
+
- Durations go to a separate histogram, `mcp.server.operation.duration`,
|
|
361
|
+
labeled only by `mcp.method.name` (one of the five methods above) and
|
|
362
|
+
`error.type`. No thrash detection or cost tracking applies.
|
|
363
|
+
- **Ordering:** instrument before registering resources/prompts, same as
|
|
364
|
+
tools. If a handler for one of these methods already exists, that method
|
|
365
|
+
is skipped with a single `diag.warn`. It never throws, so turning this on
|
|
366
|
+
can't break an existing server. On an SDK v2 `McpServer`, declaring
|
|
367
|
+
`capabilities: { resources: {}, prompts: {} }` in the constructor
|
|
368
|
+
registers those handlers immediately (and so skips them); leave them out,
|
|
369
|
+
since registering a resource or prompt declares the capability for you.
|
|
370
|
+
|
|
371
|
+
Design: [ADR 026](../../docs/adr/026-resources-prompts-coverage.md).
|
|
372
|
+
|
|
253
373
|
### Span attributes
|
|
254
374
|
|
|
255
375
|
Every span follows the OpenTelemetry MCP semantic conventions (see
|
|
@@ -273,11 +393,17 @@ success/failure through span status, not an attribute. Source of truth:
|
|
|
273
393
|
|
|
274
394
|
### Metrics
|
|
275
395
|
|
|
276
|
-
|
|
277
|
-
API-only pattern as tracing
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
`
|
|
396
|
+
The four base `mcp.tool.*` call/duration metrics below, via
|
|
397
|
+
`@opentelemetry/api`'s Metrics API — same API-only pattern as tracing
|
|
398
|
+
(see "Two modes" below): nothing is recorded until a `MeterProvider` is
|
|
399
|
+
registered — **except under `setupNodeSdk: true`, which registers one for
|
|
400
|
+
you (v0.15.0+); see "Dev-mode metrics" below.** Set `enableMetrics: false`
|
|
401
|
+
to opt out even when one is (dev-mode included); tracing is unaffected
|
|
402
|
+
either way. Source of truth: `src/metrics.js`. Twelve metrics exist
|
|
403
|
+
across the library in total — this section's four, plus two cost/token
|
|
404
|
+
metrics ("Cost & Token Attribution" below), five Agent Thrash Detection
|
|
405
|
+
metrics ("Agent Thrash Detection" below), and one schema-drift metric
|
|
406
|
+
("Tool schema drift detection" below).
|
|
281
407
|
|
|
282
408
|
| Metric | Type | Unit | Attributes | Emitted when |
|
|
283
409
|
|---|---|---|---|---|
|
|
@@ -332,12 +458,83 @@ instrumentMcpServer(server, {});
|
|
|
332
458
|
|
|
333
459
|
`http://localhost:4318` is SigNoz's default local OTLP/HTTP (OpenTelemetry
|
|
334
460
|
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
|
|
461
|
+
at your own collector in production. `@opentelemetry/exporter-metrics-otlp-http`
|
|
462
|
+
is a host-app dependency — opentel-mcp doesn't bundle it. `@opentelemetry/sdk-metrics`,
|
|
463
|
+
`@opentelemetry/sdk-trace-node`, and `@opentelemetry/exporter-trace-otlp-http`
|
|
464
|
+
are already runtime dependencies of opentel-mcp itself (`@opentelemetry/sdk-metrics`
|
|
465
|
+
as of v0.15.0, for the dev-mode metrics path below; the other two for
|
|
466
|
+
`setupNodeSdk: true`'s dev tracing path, same as before), so no extra
|
|
467
|
+
install is needed for those three — only the OTLP metrics exporter above
|
|
468
|
+
needs installing yourself.
|
|
469
|
+
|
|
470
|
+
#### Dev-mode metrics (v0.15.0+)
|
|
471
|
+
|
|
472
|
+
`setupNodeSdk: true` now also stands up an owned `MeterProvider` — the
|
|
473
|
+
same dev-friendly, zero-config posture tracing has always had under this
|
|
474
|
+
flag, extended to metrics (ADR 023, `docs/adr/023-dev-mode-metrics.md`).
|
|
475
|
+
This is a dev-mode-only exception to the API-only framing above, the same
|
|
476
|
+
way `StderrSpanExporter` (ADR 003) is a dev-mode-only exception for
|
|
477
|
+
tracing — `setupNodeSdk: false` (the default, and the production posture)
|
|
478
|
+
is completely unaffected; metrics stay exactly as API-only as they are
|
|
479
|
+
today.
|
|
480
|
+
|
|
481
|
+
What it does, under `setupNodeSdk: true` and `enableMetrics` (default
|
|
482
|
+
`true`, same flag that already opts out of metrics generally):
|
|
483
|
+
|
|
484
|
+
- Builds a `MeterProvider` with a `PeriodicExportingMetricReader` exporting
|
|
485
|
+
to stderr every 5 seconds (not the SDK's own 60-second default — a
|
|
486
|
+
quickstart user making a few tool calls should see output in a few
|
|
487
|
+
seconds, not up to a minute later), via a small custom exporter
|
|
488
|
+
(`src/exporters/stderr-metrics.js`) — same "stderr, never stdout"
|
|
489
|
+
reasoning as `StderrSpanExporter` (ADR 003): a `StdioServerTransport`
|
|
490
|
+
server's JSON-RPC stream lives on stdout, so diagnostic output of any
|
|
491
|
+
kind must go to stderr instead.
|
|
492
|
+
- Prints one line per instrument × attribute-set, not a JSON dump:
|
|
493
|
+
counters as `name{attrs} = <value>`, histograms as
|
|
494
|
+
`name{attrs} count=<n> avg=<sum/count><unit>` — see the quickstart's
|
|
495
|
+
"See it working" section above for a real example.
|
|
496
|
+
- Shares the exact same `Resource` (service.name, and the pricing-staleness
|
|
497
|
+
attribute when applicable) the dev `TracerProvider` already builds, so
|
|
498
|
+
the two signals can never carry different `service.name` values in dev
|
|
499
|
+
mode.
|
|
500
|
+
- **If a `MeterProvider` is already registered globally** — your own, or
|
|
501
|
+
anything else's — this is a complete no-op: nothing is overridden, no
|
|
502
|
+
exception is thrown, and a single `diag.warn` (once per process) says
|
|
503
|
+
so. `setupMeter()` immediately afterward continues to use whatever was
|
|
504
|
+
already registered, exactly as it does today.
|
|
505
|
+
- Extends `server.shutdown()` (already attached under `setupNodeSdk: true`)
|
|
506
|
+
to also flush and shut down the `MeterProvider` it registered, alongside
|
|
507
|
+
the existing `TracerProvider` shutdown. As of v0.16.0 this runs
|
|
508
|
+
automatically when the process stops — see "Flushing on exit" below.
|
|
509
|
+
|
|
510
|
+
### Flushing on exit (`flushOnExit`, v0.16.0)
|
|
511
|
+
|
|
512
|
+
Under `setupNodeSdk: true`, buffered spans and dev-mode metrics are
|
|
513
|
+
flushed automatically when the process stops. It's on by default; pass
|
|
514
|
+
`flushOnExit: false` to turn it off. MCP clients built on the SDK's stdio
|
|
515
|
+
transport stop a server by closing stdin, then sending SIGTERM 2 seconds
|
|
516
|
+
later, then SIGKILL; this covers the first two:
|
|
517
|
+
|
|
518
|
+
| Event | What happens |
|
|
519
|
+
| --- | --- |
|
|
520
|
+
| stdin closes and nothing else keeps the process alive | flushed on `beforeExit`; the process exits with its own exit code |
|
|
521
|
+
| `SIGTERM` / `SIGINT` | flushed, then the same signal is re-raised so the process still dies by it, unless your code has its own listener for that signal, in which case yours decides |
|
|
522
|
+
| `process.exit()` | **not covered**: call `await server.shutdown()` before exiting |
|
|
523
|
+
| `SIGKILL` | can't be intercepted |
|
|
524
|
+
|
|
525
|
+
The flush waits at most 1000 ms (`flushOnExit: { timeoutMs }` can only
|
|
526
|
+
lower that), so a stuck exporter can't keep a stopping server alive.
|
|
527
|
+
`server.shutdown()` is safe to call yourself as well: it runs once, however
|
|
528
|
+
many times it's called. With `setupNodeSdk: false` this option does
|
|
529
|
+
nothing — your application owns its providers and its shutdown. Design:
|
|
530
|
+
[ADR 024](../../docs/adr/024-flush-on-exit.md).
|
|
531
|
+
|
|
532
|
+
`@opentelemetry/sdk-metrics` is a real (non-dev) dependency of opentel-mcp
|
|
533
|
+
as of this feature — not an optional peer, not a dynamic import with a
|
|
534
|
+
fallback path. See ADR 023's "Dependency strategy" section for the full
|
|
535
|
+
reasoning: its own transitive dependencies were already in this package's
|
|
536
|
+
tree via `@opentelemetry/resources`/`@opentelemetry/sdk-trace-node`, so
|
|
537
|
+
this adds no new package beyond itself.
|
|
341
538
|
|
|
342
539
|
#### Using the Prometheus exporter instead of OTLP — `service.name` needs an extra option
|
|
343
540
|
|
|
@@ -2121,6 +2318,7 @@ so they're spelled out instead of forced into one bucket.
|
|
|
2121
2318
|
| `mcp.tool.pricing_status` | span attr, metric label | **blend — library-computed, depends on tool + host** | which of three enum values depends on a tool-controlled model name resolving (or not) in a library-default-or-host-overridden pricing table |
|
|
2122
2319
|
| `mcp.failure.fingerprint` / `.signature` / `.category` / `.origin` / `.channel` | span attr (2 also metric labels) | library-computed | hashes/enums — the governed core this project has kept clean since v0.4.0 |
|
|
2123
2320
|
| `mcp.failure.error_class` | span attr | **blend — tool-controlled, capped only** | `err.name`, length-capped at 128 characters but, unlike everything hashed into the fingerprint, *not* pattern-scrubbed — a documented, deliberate tradeoff (`docs/known-gaps.md` entry 10) |
|
|
2321
|
+
| `mcp.failure.unactionable` / `.content_length_bucket` | span attr | library-computed | a boolean and a 5-value enum derived from the trimmed text LENGTH and non-text item COUNT of an `isError: true` result — no text is read for anything but its length (ADR 025, v0.16.0) |
|
|
2124
2322
|
| `mcp.failure.validation_paths` | span attr | mostly library-computed (gated) | one legacy Zod-issue-rendering path can still carry a tool-controlled dynamic object key, now redacted to a `<KEY>` placeholder rather than closed structurally (`docs/known-gaps.md` entry 10, item 3) |
|
|
2125
2323
|
| `exception.type` (event) | event attr | tool-controlled | length-capped only under `'normalized'` mode; uncapped under `'full'` (the native SDK call) |
|
|
2126
2324
|
| `exception.message` / `.stacktrace` (event), status message | event attr + status | **blend — layered** | raw tool content under `'full'`; this library's `normalizeMessage()`/`parseAndNormalizeStack()` transform of that same content under `'normalized'`; optionally a host-supplied `errorRecording.redactor`'s output feeding *that*, first (v0.14.0) — the most layered-provenance attribute in the system |
|
|
@@ -2621,7 +2819,7 @@ pragmatic choice rather than a spec-pure one.
|
|
|
2621
2819
|
revision 2026-07-28, v0.10.0+; see "MCP v2 support" above for what's
|
|
2622
2820
|
covered and `docs/known-gaps.md` entries 6 and 8 for what isn't yet)
|
|
2623
2821
|
- @opentelemetry/api ^1.9.0
|
|
2624
|
-
-
|
|
2822
|
+
- 1017 tests, 1013 passing + 4 intentionally skipped (`npm test`) — see `test/`
|
|
2625
2823
|
- `npm run typecheck` (`tsc --noEmit`) type-checks the public `.d.ts`
|
|
2626
2824
|
surface (`src/index.d.ts` and friends) — see CONTRIBUTING.md
|
|
2627
2825
|
|
|
@@ -2701,6 +2899,17 @@ pragmatic choice rather than a spec-pure one.
|
|
|
2701
2899
|
narrower thing still open, tracked in `docs/known-gaps.md` entry 6:
|
|
2702
2900
|
`thrashSessionState`'s session-awareness memory isn't shared across v2's
|
|
2703
2901
|
per-request calls yet, only the fallback id itself.
|
|
2902
|
+
- v0.15.0: "Make it visible" ✓ — a recent audit found that this library's
|
|
2903
|
+
real depth (12 metrics, two-SDK support, a working local dashboard) was
|
|
2904
|
+
mostly invisible to a first-time user. Two fixes, no instrumentation
|
|
2905
|
+
changes: publishing `opentel-mcp-ui` (ADR 022,
|
|
2906
|
+
`docs/adr/022-publish-ui.md`) — previously a working but unpublished,
|
|
2907
|
+
`"private": true` scaffold, now `npx opentel-mcp-ui --demo`-able from a
|
|
2908
|
+
clean machine — and dev-mode metrics under `setupNodeSdk: true` (ADR
|
|
2909
|
+
023, `docs/adr/023-dev-mode-metrics.md`, see "Dev-mode metrics" above),
|
|
2910
|
+
closing the gap where the documented 30-second quickstart emitted
|
|
2911
|
+
stderr spans but none of the 12 `mcp.tool.*`/`mcp.tool.loop.*`/
|
|
2912
|
+
`mcp.tool.schema_drift.*` metrics it leads with.
|
|
2704
2913
|
- Future: failure clustering + regression detection; recovery hints;
|
|
2705
2914
|
root-cause chaining across parent spans; alignment with the OTel GenAI
|
|
2706
2915
|
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.16.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,14 @@
|
|
|
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
|
-
"@modelcontextprotocol/
|
|
76
|
+
"@modelcontextprotocol/sdk": "1.32.1",
|
|
77
|
+
"@modelcontextprotocol/server": "2.3.1",
|
|
76
78
|
"@opentelemetry/api": "^1.9.0",
|
|
77
79
|
"@opentelemetry/context-async-hooks": "^2.9.0",
|
|
78
|
-
"@opentelemetry/sdk-metrics": "^2.9.0",
|
|
79
80
|
"@opentelemetry/sdk-trace-base": "^2.9.0",
|
|
80
81
|
"@vitest/coverage-v8": "^2.1.9",
|
|
81
82
|
"js-yaml": "^4.3.1",
|
package/src/attributes.js
CHANGED
|
@@ -76,6 +76,29 @@ export const MCP_METHOD_NAME_TOOLS_CALL = 'tools/call';
|
|
|
76
76
|
/** Well-known mcp.method.name value for a tools/list request (ADR 010, schema drift detection). */
|
|
77
77
|
export const MCP_METHOD_NAME_TOOLS_LIST = 'tools/list';
|
|
78
78
|
|
|
79
|
+
// ADR 026 (v0.16.0, opt-in `coverage`): the five resource/prompt methods.
|
|
80
|
+
// mcp.method.name only ever takes these fixed string constants, never a
|
|
81
|
+
// value read from a request, so its value space stays bounded (7 total).
|
|
82
|
+
|
|
83
|
+
/** Well-known mcp.method.name value for a resources/read request (ADR 026). */
|
|
84
|
+
export const MCP_METHOD_NAME_RESOURCES_READ = 'resources/read';
|
|
85
|
+
/** Well-known mcp.method.name value for a resources/list request (ADR 026). */
|
|
86
|
+
export const MCP_METHOD_NAME_RESOURCES_LIST = 'resources/list';
|
|
87
|
+
/** Well-known mcp.method.name value for a resources/templates/list request (ADR 026). */
|
|
88
|
+
export const MCP_METHOD_NAME_RESOURCES_TEMPLATES_LIST = 'resources/templates/list';
|
|
89
|
+
/** Well-known mcp.method.name value for a prompts/get request (ADR 026). */
|
|
90
|
+
export const MCP_METHOD_NAME_PROMPTS_GET = 'prompts/get';
|
|
91
|
+
/** Well-known mcp.method.name value for a prompts/list request (ADR 026). */
|
|
92
|
+
export const MCP_METHOD_NAME_PROMPTS_LIST = 'prompts/list';
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* gen_ai.prompt.name — the prompt a prompts/get span requested (ADR 026).
|
|
96
|
+
* Span attribute only, never a metric label. Capped at
|
|
97
|
+
* MAX_PROMPT_NAME_LENGTH, since the value comes from the request.
|
|
98
|
+
*/
|
|
99
|
+
export const ATTR_GEN_AI_PROMPT_NAME = 'gen_ai.prompt.name';
|
|
100
|
+
export const MAX_PROMPT_NAME_LENGTH = 128;
|
|
101
|
+
|
|
79
102
|
// --- Custom (non-spec) attributes ---
|
|
80
103
|
|
|
81
104
|
/**
|