⚠️ Definition of Done: this issue must be completed in full, in a single PR. Do not split this
work across multiple PRs, and do not defer any Deliverable below to a follow-up issue. A PR that
satisfies only some of the Deliverables, stubs a required test, or leaves a checkbox
partially-done does NOT resolve this issue and will be closed.
Context
packages/loopover-contract/src/telemetry.ts:248 declares the span contract:
/**
* OTel span attributes for one tool call.
*
* Deliberately a STRICT SUBSET of the usage event -- no arguments, no results, not even the
* excluded-marker. ...
*/
export function buildMcpToolSpanAttributes(call: McpToolCallTelemetry): Record<string, unknown> {
return { tool, category, surface, transport, ok, duration_ms, ...(errorCode ? { error_code } : {}) };
}
src/mcp/dispatch-telemetry.ts:15 repeats the promise in the chokepoint's own header: "an OTel span
mcp.tool/<name> on the self-host path, whose attributes are a strict subset -- never arguments."
buildMcpToolSpanAttributes never reaches a span. Its only two call sites are the two structured log
lines — src/mcp/dispatch-telemetry.ts:116 and :135:
log("warn", "mcp_tool_call_failed", buildMcpToolSpanAttributes(call));
...
log("error", "mcp_tool_call_threw", buildMcpToolSpanAttributes(call));
The span's actual attributes are a separate literal built before the call runs, at
src/mcp/dispatch-telemetry.ts:84:
const attributes = { tool: toolName, category, surface: "remote" as const };
...
return await sink.withSpan(mcpToolSpanName(toolName), attributes, async () => { ... });
So a self-hosted operator's tracing backend receives mcp.tool/<name> spans carrying tool, category
and surface and nothing else. ok, transport and — the one that matters for triage — error_code
never reach the span, so a trace view cannot be filtered or grouped by cause the way the PostHog view
can. withOtelSpan (src/selfhost/otel.ts:348) sets SpanStatusCode.ERROR on a throw, which is the only
outcome signal the span carries today; the resolved closed-set code that the very same call object
already holds is dropped.
The gap is structural, not an oversight at one line: DispatchTelemetrySink.withSpan
(src/mcp/dispatch-telemetry.ts:51) takes its attributes once, at open, and exposes no way to add any
before the span ends — so instrumentToolDispatch has no seam through which to publish an outcome it only
learns after the handler returns.
Requirements
- The
mcp.tool/<name> span must carry the same attributes buildMcpToolSpanAttributes produces for the
completed call — including ok and, on a failure, error_code — on both the return path and the throw
path.
buildMcpToolSpanAttributes must be the single source of those attributes. Do not build a second
literal in src/mcp/dispatch-telemetry.ts; the pre-call literal at line 84 is either replaced or fed
from the contract helper.
DispatchTelemetrySink.withSpan gains the seam needed to publish attributes discovered after the
handler runs (for example, by having the wrapped function return the attributes alongside its result, or
by passing a setter into it). Whatever the shape, NOOP_DISPATCH_SINK
(src/mcp/dispatch-telemetry.ts:55) must remain a pure passthrough and the Worker path must stay a
passthrough at zero cost — the reason the runner is a registry (src/mcp/dispatch-span-registry.ts:8)
is that Workers has no collector.
- Span attributes must stay a strict subset of the usage event: no arguments, no results, no
payloads_excluded. buildMcpToolSpanAttributes already guarantees this and must not be widened.
- What must NOT change: the two structured log lines and their event names (
mcp_tool_call_failed,
mcp_tool_call_threw) — the self-host Loki pipeline parses them; mcpToolSpanName's
mcp.tool/<tool> format; withOtelSpan's existing status/exception-event handling in
src/selfhost/otel.ts:348-375, including the deliberate hand-built exception event that routes
through otelSafeAttributes.
- What must NOT change: the guarantee that telemetry never turns a working tool call into a failed one —
every new path stays inside the existing best-effort try/catch posture.
⚠️ Required pattern: src/mcp/dispatch-telemetry.ts:86-95's emit closure, which is built once and
called from both the return and the throw path with the completed call — the span attributes need the
same treatment. What does NOT satisfy this issue: (a) importing src/selfhost/otel.ts from
src/mcp/dispatch-telemetry.ts to reach the active span directly, which pulls the tracer into the
Cloudflare Worker bundle and defeats the registry indirection; (b) opening a second, nested span just to
carry the outcome; (c) adding ok/error_code to the pre-call literal at line 84, where neither value
is known yet.
Deliverables
All Deliverables above are required in a single PR. A PR that satisfies only some of them — for
example adding the attributes on the success path only, or changing the sink signature without wiring
the self-host runner — does not resolve this issue.
Test Coverage Requirements
This repo enforces 99%+ Codecov patch coverage, branch-counted. vitest.config.ts's
coverage.include covers src/**/*.ts (line 78) and packages/loopover-contract/src/**/*.ts (line 108),
so every touched path is measured and gated. Both arms of each branch need a test: the return path
versus the throw path in instrumentToolDispatch, the ...(call.errorCode ? { error_code } : {}) spread
in buildMcpToolSpanAttributes (already covered by test/unit/mcp-dispatch-telemetry.test.ts:71 and
:75 — keep both), the call.transport ?? "local" nullish arm, and the
withSpan ?? getMcpDispatchSpanRunner() ?? passthrough chain at
src/mcp/dispatch-telemetry-sink.ts:94, whose three arms must each be exercised.
Expected Outcome
A self-hosted operator's trace view can filter mcp.tool/* spans by outcome and by closed-set error code,
matching what the PostHog usage_event breakdown already shows, and buildMcpToolSpanAttributes is used
for the spans its name and doc describe rather than only for two log lines.
Links & Resources
packages/loopover-contract/src/telemetry.ts:248 — buildMcpToolSpanAttributes
src/mcp/dispatch-telemetry.ts:84 — the pre-call literal that is actually used as span attributes
src/mcp/dispatch-telemetry.ts:116 / :135 — the helper's only two call sites, both log lines
src/mcp/dispatch-telemetry.ts:51 — DispatchTelemetrySink.withSpan, attributes-at-open only
src/mcp/dispatch-telemetry-sink.ts:94 — the runner resolution chain
src/mcp/dispatch-span-registry.ts:8 — why the runner is a registry and not an import
src/selfhost/otel.ts:348 — withOtelSpan and otelSafeAttributes
Context
packages/loopover-contract/src/telemetry.ts:248declares the span contract:src/mcp/dispatch-telemetry.ts:15repeats the promise in the chokepoint's own header: "an OTel spanmcp.tool/<name>on the self-host path, whose attributes are a strict subset -- never arguments."buildMcpToolSpanAttributesnever reaches a span. Its only two call sites are the two structured loglines —
src/mcp/dispatch-telemetry.ts:116and:135:The span's actual attributes are a separate literal built before the call runs, at
src/mcp/dispatch-telemetry.ts:84:So a self-hosted operator's tracing backend receives
mcp.tool/<name>spans carryingtool,categoryand
surfaceand nothing else.ok,transportand — the one that matters for triage —error_codenever reach the span, so a trace view cannot be filtered or grouped by cause the way the PostHog view
can.
withOtelSpan(src/selfhost/otel.ts:348) setsSpanStatusCode.ERRORon a throw, which is the onlyoutcome signal the span carries today; the resolved closed-set code that the very same
callobjectalready holds is dropped.
The gap is structural, not an oversight at one line:
DispatchTelemetrySink.withSpan(
src/mcp/dispatch-telemetry.ts:51) takes its attributes once, at open, and exposes no way to add anybefore the span ends — so
instrumentToolDispatchhas no seam through which to publish an outcome it onlylearns after the handler returns.
Requirements
mcp.tool/<name>span must carry the same attributesbuildMcpToolSpanAttributesproduces for thecompleted call — including
okand, on a failure,error_code— on both the return path and the throwpath.
buildMcpToolSpanAttributesmust be the single source of those attributes. Do not build a secondliteral in
src/mcp/dispatch-telemetry.ts; the pre-call literal at line 84 is either replaced or fedfrom the contract helper.
DispatchTelemetrySink.withSpangains the seam needed to publish attributes discovered after thehandler runs (for example, by having the wrapped function return the attributes alongside its result, or
by passing a setter into it). Whatever the shape,
NOOP_DISPATCH_SINK(
src/mcp/dispatch-telemetry.ts:55) must remain a pure passthrough and the Worker path must stay apassthrough at zero cost — the reason the runner is a registry (
src/mcp/dispatch-span-registry.ts:8)is that Workers has no collector.
payloads_excluded.buildMcpToolSpanAttributesalready guarantees this and must not be widened.mcp_tool_call_failed,mcp_tool_call_threw) — the self-host Loki pipeline parses them;mcpToolSpanName'smcp.tool/<tool>format;withOtelSpan's existing status/exception-event handling insrc/selfhost/otel.ts:348-375, including the deliberate hand-builtexceptionevent that routesthrough
otelSafeAttributes.every new path stays inside the existing best-effort try/catch posture.
Deliverables
DispatchTelemetrySink.withSpan's signature gains a seam for post-hoc attributes, andNOOP_DISPATCH_SINKpluscreateDispatchTelemetrySink(
src/mcp/dispatch-telemetry-sink.ts:75) both implement it.instrumentToolDispatchpublishesbuildMcpToolSpanAttributes(call)onto the span on the returnpath and on the throw path.
src/selfhost/otel.ts's runner (or the closure the self-host entry registers viasetMcpDispatchSpanRunner) applies those attributes to the real span throughotelSafeAttributes,the same scrubber every other attribute goes through.
test/unit/mcp-dispatch-telemetry.test.tsnamed for this bug that injects arecording
withSpansink, runs a handler that returns normally and one that throws, and asserts thespan for each ends with
okand — for the throw — anerror_codedrawn fromMCP_TELEMETRY_ERROR_CODES.NOOP_DISPATCH_SINK.withSpanis still a pure passthrough that records nothing andreturns the handler's value unchanged.
All Deliverables above are required in a single PR. A PR that satisfies only some of them — for
example adding the attributes on the success path only, or changing the sink signature without wiring
the self-host runner — does not resolve this issue.
Test Coverage Requirements
This repo enforces 99%+ Codecov patch coverage, branch-counted.
vitest.config.ts'scoverage.includecoverssrc/**/*.ts(line 78) andpackages/loopover-contract/src/**/*.ts(line 108),so every touched path is measured and gated. Both arms of each branch need a test: the return path
versus the throw path in
instrumentToolDispatch, the...(call.errorCode ? { error_code } : {})spreadin
buildMcpToolSpanAttributes(already covered bytest/unit/mcp-dispatch-telemetry.test.ts:71and:75— keep both), thecall.transport ?? "local"nullish arm, and thewithSpan ?? getMcpDispatchSpanRunner() ?? passthroughchain atsrc/mcp/dispatch-telemetry-sink.ts:94, whose three arms must each be exercised.Expected Outcome
A self-hosted operator's trace view can filter
mcp.tool/*spans by outcome and by closed-set error code,matching what the PostHog
usage_eventbreakdown already shows, andbuildMcpToolSpanAttributesis usedfor the spans its name and doc describe rather than only for two log lines.
Links & Resources
packages/loopover-contract/src/telemetry.ts:248—buildMcpToolSpanAttributessrc/mcp/dispatch-telemetry.ts:84— the pre-call literal that is actually used as span attributessrc/mcp/dispatch-telemetry.ts:116/:135— the helper's only two call sites, both log linessrc/mcp/dispatch-telemetry.ts:51—DispatchTelemetrySink.withSpan, attributes-at-open onlysrc/mcp/dispatch-telemetry-sink.ts:94— the runner resolution chainsrc/mcp/dispatch-span-registry.ts:8— why the runner is a registry and not an importsrc/selfhost/otel.ts:348—withOtelSpanandotelSafeAttributes