Metrics
The six mcp.tool.* OpenTelemetry metrics opentel-mcp emits — calls, errors, silent_failures, duration, tokens.total, and cost.total — with SigNoz and Prometheus query examples.
opentel-mcp emits six OpenTelemetry metrics instruments through
@opentelemetry/api's Metrics API: mcp.tool.calls, mcp.tool.errors,
mcp.tool.silent_failures, mcp.tool.duration, and — new in v0.5.0 —
mcp.tool.tokens.total and mcp.tool.cost.total. All six come from
src/metrics.js's setupMeter(). The first four are recorded from the
same isToolResultError() check that marks
spans ERROR, so the metric and the trace never disagree about whether
a call failed; the two cost/token counters are recorded from
Cost & Token Attribution's applyCostAttribution()
instead, independently of call outcome.
TL;DR
Six instruments, all under the mcp.tool.* namespace:
mcp.tool.calls (every call), mcp.tool.errors (thrown/rejected),
mcp.tool.silent_failures (isError: true), mcp.tool.duration
(histogram, milliseconds), mcp.tool.tokens.total (counter, tokens),
and mcp.tool.cost.total (counter, USD). Nothing is recorded until
your application registers an OpenTelemetry MeterProvider — until
then, recording through these instruments is a zero-overhead no-op,
the default @opentelemetry/api behavior.
What are the six instruments?
| Metric | Type | Unit | Attributes | Emitted when |
|---|---|---|---|---|
mcp.tool.calls | Counter | — | gen_ai.tool.name, mcp.method.name | Every tool call, regardless of outcome |
mcp.tool.errors | Counter | — | gen_ai.tool.name, error.type1 | Handler threw or its promise rejected |
mcp.tool.silent_failures | Counter | — | gen_ai.tool.name1 | Result had isError: true |
mcp.tool.duration | Histogram | ms | gen_ai.tool.name, mcp.tool.outcome1 | Every call, on completion |
mcp.tool.tokens.total | Counter | tokens | gen_ai.tool.name, mcp.tool.model2 | Usage detected in the tool result |
mcp.tool.cost.total | Counter | USD | gen_ai.tool.name, mcp.tool.model2 | Cost calculated (model resolved in pricingTable) |
mcp.tool.duration's mcp.tool.outcome attribute is one of success,
error, or silent_failure — the same three-way split as the
instrument names, but on a single histogram so you can compare call
latency across all three outcomes without joining against error.type
(which silent failures don't set on the duration metric).
Why is mcp.tool.silent_failures a separate counter from mcp.tool.errors?
Because they're different failure modes and mixing them into one counter
would hide which one is happening. mcp.tool.errors counts handlers that
threw or rejected — a JavaScript-level exception. mcp.tool.silent_failures
counts handlers that returned normally with CallToolResult.isError: true — see Silent Failures for why that case is
easy to miss entirely without this counter. A tool that's failing by
throwing and a tool that's failing by returning isError: true usually
point at different bugs; a single alert on both would tell you that
something's wrong but not what.
Do I need to configure anything to get these?
No setup beyond registering a MeterProvider somewhere in your
application — instrumentMcpServer() doesn't create one for you (unlike
its tracing side, which can optionally create its own NodeTracerProvider
via setupNodeSdk: true). Set enableMetrics: false in
instrumentMcpServer()'s options to opt out of
metrics specifically while keeping tracing; tracing has no equivalent
metrics-only switch since it isn't one.
How do I query these in SigNoz?
Illustrative
The wiring code below is real — copied from opentel-mcp's own README. The query text is illustrative: it shows the shape of a useful query against these metric names, not a query captured from a live SigNoz instance.
Wiring a MeterProvider that exports to SigNoz's default local OTLP/HTTP
endpoint:
import { metrics } from '@opentelemetry/api';
import { MeterProvider, PeriodicExportingMetricReader } from '@opentelemetry/sdk-metrics';
import { OTLPMetricExporter } from '@opentelemetry/exporter-metrics-otlp-http';
import { resourceFromAttributes } from '@opentelemetry/resources';
const meterProvider = new MeterProvider({
resource: resourceFromAttributes({ 'service.name': 'my-mcp-server' }),
readers: [
new PeriodicExportingMetricReader({
exporter: new OTLPMetricExporter({ url: 'http://localhost:4318/v1/metrics' }),
}),
],
});
metrics.setGlobalMeterProvider(meterProvider);With that registered before instrumentMcpServer() runs, a SigNoz
dashboard panel querying mcp.tool.silent_failures grouped by
gen_ai.tool.name shows which tools are failing silently, and how often,
without opening a single trace.
How do I query cost per tool in SigNoz?
Illustrative
Same caveat as above: the metric names and attributes are real, the query text illustrates the shape of a useful query, not a capture from a live SigNoz instance.
With the same MeterProvider registered, a SigNoz dashboard panel
summing mcp.tool.cost.total grouped by gen_ai.tool.name over a time
range shows which tools are actually driving LLM spend:
sum(mcp.tool.cost.total) by (gen_ai.tool.name)
That only reflects calls where Cost & Token Attribution
detected a model and that model resolved in the configured
pricingTable — see that page for what happens when it doesn't.
How do I query token spend by model?
Group the same counter — or mcp.tool.tokens.total for raw token
volume instead of dollars — by mcp.tool.model (or the co-emitted
gen_ai.response.model, for dashboards already built around the OTel
GenAI semantic conventions) instead of tool name:
sum(mcp.tool.tokens.total) by (mcp.tool.model)
Useful for answering "which model is actually consuming the tokens,"
independent of which tool called it — a tool that calls multiple models
conditionally would otherwise hide that split behind a single
gen_ai.tool.name grouping.
How do I export these to Prometheus?
Register a Prometheus exporter as the MeterProvider's reader instead of
an OTLP one — nothing about instrumentMcpServer() changes. Verified
against @opentelemetry/exporter-prometheus current stable — pin your
version and check the package README if you upgrade:
import { metrics } from '@opentelemetry/api';
import { MeterProvider } from '@opentelemetry/sdk-metrics';
import { PrometheusExporter } from '@opentelemetry/exporter-prometheus';
const meterProvider = new MeterProvider({
readers: [new PrometheusExporter({ port: 9464 })],
});
metrics.setGlobalMeterProvider(meterProvider);OpenTelemetry's Prometheus exporter converts metric names by replacing
. with _ and appending _total to counters, so
mcp.tool.silent_failures becomes mcp_tool_silent_failures_total in
PromQL:
sum(rate(mcp_tool_silent_failures_total[5m])) by (gen_ai_tool_name)That gives a per-tool silent-failure rate over a 5-minute window — a reasonable starting alert query, tune the window and threshold to your own traffic.
Where do I go from here?
- Silent Failures — what
isError: truemeans and whymcp.tool.silent_failuresexists at all. - Deep Failure Fingerprinting — the
mcp.failure.categoryattribute these metrics carry when fingerprinting classifies a failure. - Cost & Token Attribution — how
mcp.tool.tokens.totalandmcp.tool.cost.totalget populated, and the span attributes that go with them. - API Reference —
enableMetricsand the rest ofinstrumentMcpServer()'s options.
Footnotes
-
Also carries
mcp.failure.categorywhen Deep Failure Fingerprinting classified the failure — see that page for the 8 categories. ↩ ↩2 ↩3 -
mcp.tool.modelis only added when Cost & Token Attribution's extractor detected a model name — the same optional-attribute cardinality patternmcp.failure.categoryuses on the other four instruments. ↩ ↩2