Skip to content

Tracing

Prerequisites

Install Vurb.ts before following this guide: npm install @vurb/core @modelcontextprotocol/sdk zod — or scaffold a project with vurb create.

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

typescript
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.

ScenarioSpanStatusCodemcp.error_typerecordException?
Handler returns success()OK (1)No
Handler returns error()UNSET (0)handler_returned_errorNo
Validation failureUNSET (0)validation_failedNo
Missing discriminatorUNSET (0)missing_discriminatorNo
Unknown actionUNSET (0)unknown_actionNo
Handler throwsERROR (2)system_errorYes

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.3

TIP

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:

EventAttributesWhen
mcp.routeDiscriminator resolved
mcp.validatemcp.valid, mcp.durationMsAfter Zod validation
mcp.middlewaremcp.chainLengthWhen 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:

typescript
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:

typescript
registry.enableDebug(createDebugObserver());
registry.enableTracing(tracer);
// ⚠️ Warning: Tracing takes precedence; debug events will not be emitted.

Span Lifecycle

text
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:

typescript
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

typescript
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.