Introducing Deep Failure Fingerprinting in opentel-mcp v0.4.0
opentel-mcp v0.4.0 ships Deep Failure Fingerprinting: every thrown error
and every silent failure (isError: true) now
gets a stable 16-character identity, computed locally, that groups
identical underlying failures together — even when the error message
itself is different every time.
TL;DR
A SHA-256 hash, truncated to 16 hex characters, computed from a
normalized error class, category, origin, and stack signature — not the
raw message or raw stack, which would defeat grouping. Only two of the
five resulting attributes (category, origin) are safe to put on a
metric label; that's enforced structurally, not by convention. See
Deep Failure Fingerprinting for the full
reference.
What problem does this solve?
Before v0.4.0, opentel-mcp gave you a span marked ERROR on every
failure, but every failure looked like its own, unrelated incident. Ten
calls that failed for the exact same reason — the same downstream
timeout, the same validation bug — showed up as ten separate errors in a
dashboard, distinguished only by whatever incidental detail (a request
ID, a timestamp, a user ID) happened to be in the message. Nothing tied
them together as "this is one bug happening ten times" versus "these are
ten different bugs." Deep Failure Fingerprinting exists to answer that
question directly: same fingerprint, same underlying bug.
How is the fingerprint computed?
The pipeline classifies the failure into one of 8 categories
(validation, timeout, network, auth, dependency,
serialization, internal, unknown) through seven ordered
classifiers, normalizes the error message and stack trace to strip
high-cardinality noise like UUIDs and timestamps, and folds all of it
into a versioned input string that gets hashed:
// src/fingerprint/hash.js
export function hashInputs(input) {
return createHash('sha256').update(input, 'utf8').digest('hex').slice(0, 16);
}SHA-256 truncated to 16 hex characters (64 bits) — enough for identity
at the scale this is meant for (roughly 1-in-4-billion collision odds at
100,000 unique fingerprints), fast enough to stay well inside a
per-failure budget of a couple hundred microseconds, and native to
Node's crypto module so it doesn't add a runtime dependency. The full
reasoning, including why xxhash and murmurhash were considered and
rejected, is in the source itself — see
Deep Failure Fingerprinting for the
complete pipeline.
Why does cardinality safety matter here specifically?
Because a fingerprint is, by design, unbounded — a new bug means a new fingerprint, forever. That's exactly right for a span attribute (each span is its own record, so an unbounded value costs nothing extra), and exactly wrong for a metric label, where every distinct value becomes its own permanent time series. Put an unbounded fingerprint on a counter and the counter's cardinality grows forever alongside your bug count.
opentel-mcp enforces the boundary in code, not in a review checklist:
// src/fingerprint/attributes.js
export const METRIC_SAFE_ATTRIBUTES = Object.freeze([
ATTRIBUTE_KEYS.CATEGORY, // 'mcp.failure.category' — 8 values
ATTRIBUTE_KEYS.ORIGIN, // 'mcp.failure.origin' — 3 values
]);src/metrics.js can only reach a fingerprint-derived value through this
one frozen array. fingerprint, signature, and error_class — the
higher-cardinality attributes — have no code path to a metric label at
all. Twenty-four combinations of category and origin, maximum, ever.
How was this tested?
136 tests, zero regressions, across the full opentel-mcp suite by the
time v0.4.0 shipped — including a dedicated benchmark asserting the
fingerprinting pipeline stays under its microsecond-scale performance
budget, since this runs inline on every single failure the instrumented
server sees. computeFingerprint() also runs its entire pipeline inside
one top-level try/catch specifically so a bug in fingerprinting itself
can never surface as an exception into the tool call it's observing — if
anything goes wrong internally, it falls back to a fixed identity
(0000000000000000) instead.
How was this actually built?
With Claude Code, in eight narrow, reviewable phases rather than one
large generation pass — extend the failure classifiers, build the
message- and stack-normalization pipeline, wire the hash function, thread
the resulting attributes through the span and metric code, write the
tests for each piece as it landed. Each phase was small enough to review
completely before moving to the next, which is the same discipline this
website's own build has followed (see the site repo's own CLAUDE.md
and DECISIONS.md if you're curious what that looks like from the
outside): narrow prompts, verify against the real source before writing
anything down, log every non-obvious call instead of leaving it
implicit.
What's next?
computeFingerprint()'s classifiers and stackFrames options aren't
wired through instrumentMcpServer()'s own options yet — using a custom
classifier today means calling computeFingerprint() directly. That's
planned for v0.5.0, along with adding fingerprinting to the published
InstrumentOptions TypeScript interface, which currently lags what
src/config.js actually implements. See
the changelog for what's shipped so far.
Where do I go from here?
- Deep Failure Fingerprinting — full reference: all 8 categories, all 5 attributes, the fallback behavior.
- Metrics — where
mcp.failure.categoryshows up on themcp.tool.errorsandmcp.tool.silent_failurescounters. - Silent Failures — the
isError: truedetection everytool_error-origin fingerprint starts from.