Appearance
Local Telemetry (Jaeger)
Every span apps/web already emits to Datadog in production can be sent to a Jaeger running on your laptop instead. Same instrumentation, same code path — only the exporter's destination changes.
It is worth the ten minutes because it answers questions logs cannot. A log line tells you what one function decided; a trace tells you the request called Supabase twice, waited 15 seconds on a compile, and died on a fetch to a port with nothing behind it — with the failing child span named.
Verified observations
Used on 2026-08-13 while chasing a 500 on /api/admin/voice-assistants/[id]/data. The server log showed a VapiError; the trace showed the whole request — 43 spans, everything green except one child fetch GET http://localhost:3005/… failing 88ms before the 500. That named the cause in one view.
What you are installing
One process and two environment variables. Nothing in application code.
| Piece | What it is |
|---|---|
| Jaeger v2 | Trace store + MCP on :16686, OTLP intake on :4318 |
OTEL_EXPORTER_OTLP_ENDPOINT | Where the app sends spans |
OTEL_EXPORTER_OTLP_PROTOCOL | http/protobuf |
One Jaeger serves every worktree on the machine — you do not need one per tree.
1. Run Jaeger
MCP moved in 2.20 — old configs break on latest
Through 2.19 the MCP server was a separate jaeger_mcp extension on its own port. In 2.20 it was merged into jaeger_query: enable it with ai: { enable_mcp: true } and it is served on the query port at /api/ai/mcp/. The config below is the 2.20+ form.
If you copied a pre-2.20 config, latest refuses to start with 'extensions' unknown type: "jaeger_mcp" — that is this change, not a broken image. (Older daemons still running the standalone extension are fine; they just serve MCP on their own port.)
Write jaeger.yaml:
yaml
service:
extensions: [jaeger_storage, jaeger_query]
pipelines:
traces:
receivers: [otlp]
processors: [batch]
exporters: [jaeger_storage_exporter]
extensions:
jaeger_storage:
backends:
main:
memory:
max_traces: 100000
jaeger_query:
storage:
traces: main
http:
endpoint: 0.0.0.0:16686
ai:
enable_mcp: true
receivers:
otlp:
protocols:
http:
endpoint: 0.0.0.0:4318
processors:
batch: {}
exporters:
jaeger_storage_exporter:
trace_storage: mainThen:
bash
docker run -d --name jaeger \
-p 16686:16686 -p 4318:4318 \
-v "$PWD/jaeger.yaml:/jaeger.yaml" \
jaegertracing/jaeger:latest --config=file:/jaeger.yamlAlready running it without knowing
If your tooling ships a telemetry daemon, check before starting a second one — lsof -iTCP:16686 -sTCP:LISTEN. InDusk's, for example, runs Jaeger v2 with MCP already enabled, so steps 1 and 4 are done for you and only the env vars in step 2 are yours to set — but check its version and use the matching MCP URL from step 4.
Confirm it is up:
bash
curl -s -o /dev/null -w '%{http_code}\n' localhost:16686 # 200Stop and start with docker stop jaeger / docker start jaeger; remove with docker rm -f jaeger.
Spans emitted while Jaeger is down are lost
There is no client-side buffering. Start it before the thing you want to trace, not after.
2. Point the app at it
Add to apps/web/.env.local:
bash
OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318
OTEL_EXPORTER_OTLP_PROTOCOL=http/protobufNothing else. @vercel/otel — already wired up by packages/observability/src/register.ts for Datadog — reads the standard OTLP environment variables at boot, so pointing them at localhost redirects the existing pipeline. No exporter to configure, no code to change.
Restart the dev server. instrumentation.ts registers OpenTelemetry once at startup; editing env or instrumentation source while it runs changes nothing.
Do it once, not once per worktree
If your setup seeds new worktrees from a shared env file, put these two lines there rather than in each tree — every future worktree then gets tracing without anyone remembering. Seeding is not retroactive, so patch existing trees by hand.
3. Look at a trace
Open http://localhost:16686, pick avoca-web, hit Find Traces.
Or query it as JSON, which is usually faster when you know what you are after:
bash
curl -s "localhost:16686/api/services"
curl -s "localhost:16686/api/traces?service=avoca-web&limit=50&lookback=15m"Useful shape of a trace response: data[].spans[] each carry operationName, duration (microseconds), tags[], and logs[] where exceptions live. Resource attributes are under data[].processes — those apply to every span in the trace.
To find failures specifically, filter on the error tag:
bash
curl -s 'localhost:16686/api/traces?service=avoca-web&lookback=1h&tags=%7B%22error%22%3A%22true%22%7D'4. Let an agent read it (MCP)
Jaeger v2 ships its own MCP server — nothing extra to install. With enable_mcp set above it rides the query port. Add to .mcp.json at the repo root:
json
{
"mcpServers": {
"jaeger": { "type": "http", "url": "http://localhost:16686/api/ai/mcp/" }
}
}The URL differs by version — check before assuming it is down
| Jaeger | MCP endpoint |
|---|---|
2.20+ (latest) | http://localhost:16686/api/ai/mcp/ |
| ≤ 2.19 | http://localhost:<mcp-port>/mcp (own port, default 16687) |
A pre-2.20 daemon someone else started serves the old URL, so point .mcp.json at whichever one is actually listening rather than at the version you expected.
That exposes eight tools:
| Tool | Use |
|---|---|
get_services | which services have emitted — start here |
search_traces | find traces by service, time, attributes, duration |
get_trace_topology | a trace's structure without the verbose payload |
get_span_details | attributes, events, status for specific spans |
get_trace_errors | every error span in a trace |
get_critical_path | the spans that actually drove latency |
get_span_names | operation names for a service |
health | is the MCP server up |
Start Jaeger BEFORE your agent session
MCP servers are dialed once, when the session starts. If Jaeger is not listening at that moment the client marks it Failed and never retries — the status stays failed for the whole session even after Jaeger comes up. Start Jaeger first, or reconnect from inside the session (/mcp in Claude Code). This is the single most common reason it "does not work".
Everything the MCP tools do is also reachable over plain HTTP, which is handy in scripts:
bash
curl -s localhost:16686/api/services
curl -s 'localhost:16686/api/traces?service=avoca-web&limit=50&lookback=15m'
curl -s 'localhost:16686/api/traces?service=avoca-web&lookback=1h&tags=%7B%22error%22%3A%22true%22%7D'Tearing it down
bash
docker rm -f jaegerRemoving the two env vars sends the app back to its default exporter.
Gotchas
A short-lived script must flush. Span processors batch, so a script that finishes and exits takes the batch with it and Jaeger shows nothing — a failure indistinguishable from "no spans were emitted". Call forceFlush before exiting, and set process.exitCode rather than calling process.exit(): forceFlush resolves when the batch reaches the exporter, not when its HTTP request completes, so exiting severs the socket.
Every worktree reports the same service.name. Two trees both emit as avoca-web, into the same Jaeger, and nothing in a span says which tree, branch or process produced it. Today the only way to tell is lsof on the port followed by checking the process's working directory. Adding process.pid and service.instance.id as resource attributes fixes this — see the note below.
"No spans" has two causes. Either nothing ran, or Jaeger is down. Check curl -s -o /dev/null -w '%{http_code}' localhost:16686 before diagnosing anything else.
Nothing is persisted. The config above stores traces in memory, bounded by max_traces, and loses everything on restart. This is a dev-loop buffer, not an archive — production questions go to Datadog.
Unsolicited Opinion
The resource attributes are the highest-value thing you can add here. Every span already carries deployment.environment, node.env, process.runtime.name and vercel.runtime — all identical for every instance of the service, which makes several questions unanswerable rather than merely hard: was a second dev server running against this build directory, did these two requests share a process, is this slowness one sick instance or the whole fleet? Two more attributes (process.pid, service.instance.id) turn each of those into a group-by, cost nothing to maintain because resource attributes attach to every span automatically — including auto-instrumented fetch spans nobody wrote — and help in production too, where Fluid Compute packs many requests into one process.