Tracing
Prerequisites
Install Vurb.ts before following this guide: npm install @vurb/core @modelcontextprotocol/sdk zod — or scaffold a project with vurb create.
- Introduction
- Quick Start
- Error Classification
- Span Attributes
- Pipeline Events
- VurbTracer Interface
- Coexistence with Debug
- Span Lifecycle
- Context Propagation
- Production Setup
Introduction
When an MCP tool call fails in production, you need to know exactly where and why — was it a validation error, a middleware guard, or a handler exception? Standard logging gives you "error occurred". OpenTelemetry tracing gives you the full pipeline: route → validate → middleware → execute, with durations, attributes, and semantic error classification.
Vurb.ts produces OpenTelemetry-compatible spans with zero dependencies — structural subtyping, not import @opentelemetry/api. Zero overhead when disabled — a completely separate code path runs.
Quick Start
import { trace } from '@opentelemetry/api';
import { ToolRegistry } from '@vurb/core';
const registry = new ToolRegistry<AppContext>();
registry.registerAll(projectsTool, billingTool, usersTool);
registry.attachToServer(server, {
contextFactory: createAppContext,
tracing: trace.getTracer('Vurb.ts'),
});All tools now emit spans. Also available per-tool (.tracing(tracer)) or registry-wide (registry.enableTracing(tracer)).
Error Classification
AI agents fail differently than humans. An LLM sends invalid parameters hundreds of times while self-correcting. If every validation failure set SpanStatusCode.ERROR, your on-call engineer would drown in false alerts.
| Scenario | SpanStatusCode | mcp.error_type | recordException? |
|---|---|---|---|
Handler returns success() | OK (1) | — | No |
Handler returns error() | UNSET (0) | handler_returned_error | No |
| Validation failure | UNSET (0) | validation_failed | No |
| Missing discriminator | UNSET (0) | missing_discriminator | No |
| Unknown action | UNSET (0) | unknown_action | No |
| Handler throws | ERROR (2) | system_error | Yes |
Only an unhandled exception sets ERROR. Everything else uses UNSET — the AI self-corrects, the server stays alive.
# PagerDuty: only infra failures
SpanStatusCode:ERROR service:Vurb.ts
# Datadog: AI error rate
count(mcp.error_type:validation_failed) / count(*) > 0.5
# Grafana: handler error rate for SLO
count(mcp.error_type:handler_returned_error) / count(*) > 0.3TIP
Set up PagerDuty alerts on SpanStatusCode.ERROR only — these are real infrastructure failures. Use separate dashboards for mcp.error_type to track AI self-correction patterns.
Span Attributes
Every span includes: mcp.system ("Vurb.ts"), mcp.tool (tool name), mcp.durationMs (total execution time), mcp.isError (boolean), mcp.response_size (response text length).
Routing attributes: mcp.action (resolved action), mcp.error_type (classification label).
Conditional attributes: mcp.tags (string[], when configured), mcp.description (when configured). Tags enable dashboard filtering:
# Datadog: PCI-scoped calls
mcp.tags:pci service:Vurb.ts
# Grafana: admin tools only
{mcp_tags=~".*admin.*"}Pipeline Events
Each span contains structured events tracing internal execution:
| Event | Attributes | When |
|---|---|---|
mcp.route | — | Discriminator resolved |
mcp.validate | mcp.valid, mcp.durationMs | After Zod validation |
mcp.middleware | mcp.chainLength | When middleware chain exists |
Events use optional chaining (addEvent?.()), so tracers that don't implement addEvent work fine.
VurbTracer Interface
Any object with the right shape works. The real @opentelemetry/api Tracer satisfies this automatically:
interface VurbTracer {
startSpan(name: string, options?: {
attributes?: Record<string, string | number | boolean | ReadonlyArray<string>>;
}): VurbSpan;
}
interface VurbSpan {
setAttribute(key: string, value: string | number | boolean | ReadonlyArray<string>): void;
setStatus(status: { code: number; message?: string }): void;
end(): void;
recordException(exception: Error | string): void;
addEvent?(name: string, attributes?: Record<string, string | number | boolean>): void;
}SpanStatusCode constants: UNSET (0), OK (1), ERROR (2) — exported from Vurb.ts.
Coexistence with Debug
Both tracing and debug can be configured, but tracing takes precedence. When both are set, debug events are not emitted to avoid duplicate overhead:
registry.enableDebug(createDebugObserver());
registry.enableTracing(tracer);
// ⚠️ Warning: Tracing takes precedence; debug events will not be emitted.Span Lifecycle
span = tracer.startSpan(...)
try {
route → validate → middleware → execute
} catch {
recordException + ERROR status
} finally {
setAttribute('mcp.durationMs', ...)
setAttribute('mcp.response_size', ...)
setStatus(...)
span.end() // always called
}The finally block guarantees no span leaks — duration and response size recorded even on exceptions.
Context Propagation
Vurb.ts doesn't depend on @opentelemetry/api, so it cannot inject span context automatically. Auto-instrumented downstream calls (Prisma, HTTP, Redis) appear as sibling spans, not children. For manual propagation:
import { context, trace } from '@opentelemetry/api';
const dbQuery = f.query('db.query')
.describe('Run a database query')
.withString('sql', 'SQL query')
.handle(async (input, ctx) => {
const span = trace.getActiveSpan();
return context.with(
trace.setSpan(context.active(), span!),
() => ctx.db.query(input.sql),
);
});Production Setup
import { NodeSDK } from '@opentelemetry/sdk-node';
import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-grpc';
import { trace } from '@opentelemetry/api';
import { ToolRegistry } from '@vurb/core';
const sdk = new NodeSDK({
traceExporter: new OTLPTraceExporter({
url: process.env.OTLP_ENDPOINT ?? 'http://localhost:4317',
}),
serviceName: 'my-mcp-server',
});
sdk.start();
const tracer = trace.getTracer('Vurb.ts', '1.0.0');
const registry = new ToolRegistry<AppContext>();
registry.registerAll(projectsTool, billingTool, usersTool);
registry.attachToServer(server, {
contextFactory: createAppContext,
tracing: tracer,
});Spans appear in Jaeger, Datadog, Grafana Tempo, or any OTLP-compatible backend.