A small local OTLP collector written in Bun + Hono. It accepts OpenTelemetry traces, metrics, and logs over all three OTLP transports (http/json, http/protobuf, and grpc) and appends every payload — fully decoded to JSON — to newline-delimited JSON files on disk.
It exists so you can point any OTLP producer (Claude Code, OpenAI Codex, an app you're debugging, a CI job, …) at http://localhost:4318 or http://localhost:4317 and grep/jq the raw telemetry without spinning up Jaeger, Tempo, Prometheus, or any other backend.
producer (claude, codex, app) ──OTLP──> otel-interceptor ──NDJSON──> data/{traces,metrics,logs}.ndjson
| Transport | Port | Path(s) | Encoding |
|---|---|---|---|
| OTLP/gRPC | 4317 |
ExportTraceService, ExportMetricsService, ExportLogsService |
protobuf |
| OTLP/HTTP (proto) | 4318 |
/v1/traces, /v1/metrics, /v1/logs |
application/x-protobuf |
| OTLP/HTTP (json) | 4318 |
/v1/traces, /v1/metrics, /v1/logs |
application/json |
All three signals (traces, metrics, logs) work over all three transports. Both ports bind on 0.0.0.0 by default so docker and remote boxes can reach them; override with env vars if you want.
Requirements: bun >= 1.1, task (brew install go-task/tap/go-task).
task install # bun install
task start # runs on :4318 (http) + :4317 (grpc)Without task:
bun install
bun run src/server.tsYou should see:
otel-interceptor ready
http 0.0.0.0:4318 (OTLP/HTTP json + protobuf)
grpc 0.0.0.0:4317 (OTLP/gRPC)
data /…/otel-interceptor/data
Each incoming export is appended as a single line to data/<signal>.ndjson. One payload = one line. The structure of every line:
The payload is the full OTLP request decoded to JSON. traceId, spanId, and parentSpanId are normalized to lowercase hex regardless of whether the source sent them as hex (OTel JS, Python) or base64 (strict proto3 JSON). Everything else follows OTLP/JSON: attribute keys are camelCase, enums are strings (e.g. "SPAN_KIND_INTERNAL"), and 64-bit ints are JSON strings.
task --list
* clean: Delete all captured NDJSON in the data dir.
* dev: Run with hot reload on source changes.
* install: Install dependencies with bun.
* smoke: Send a test span via HTTP/JSON to verify the server is up.
* start: Run the OTLP interceptor (HTTP on 4318, gRPC on 4317).
* stats: Show how many traces/metrics/logs payloads have been captured.
* compact:traces: Group spans by traceId -> data/traces-by-trace.jsonl (one-shot).
* compact:all: Same, also attaching log records whose traceId matches (one-shot).
* compact:watch: Watch traces.ndjson and rewrite the JSONL on change.
* compact:watch:all: Same, also merging matching logs on change.
* tail:all: Follow all three NDJSON files, tagged by signal.
* tail:logs: Follow captured logs NDJSON (pretty-printed).
* tail:metrics: Follow captured metrics NDJSON (pretty-printed).
* tail:traces: Follow captured traces NDJSON (pretty-printed).
Typical workflow: task start in one pane, task compact:watch:all in another, then run whatever producer you want in a third.
The server NDJSON files are one line per OTLP export request, which means a single trace can be spread across many lines and many files as its spans arrive in batches. For analysis it's often nicer to have one line per trace.
src/compact-traces.ts reads data/traces.ndjson, groups every span by traceId, and writes data/traces-by-trace.jsonl where each line looks like:
{
"traceId": "c39c72dabeb2c3fe8aab4fdacb5805d8",
"spanCount": 3,
"logCount": 0,
"startTimeUnixNano": "1776734773675000000",
"endTimeUnixNano": "1776734773700381000",
"durationMs": 25.381,
"services": ["claude-code"],
"spans": [
{ "spanId": "4523...", "parentSpanId": null, "name": "claude_code.interaction", "resource": {...}, "scope": {...}, ... },
{ "spanId": "46d6...", "parentSpanId": "4523...", "name": "claude_code.llm_request", ... }
]
}Spans are sorted by startTimeUnixNano, resource + scope are inlined on each span (so a row is self-contained), and trace-level startTimeUnixNano / endTimeUnixNano / durationMs span the whole trace.
task compact:traces # traces only
task compact:all # also attach logs whose top-level traceId matchestask compact:watch # traces only
task compact:watch:all # also merging matching logsWatch mode uses both fs.watch and a 1 s polling fallback so it works reliably on macOS, Linux, and networked filesystems. Each change re-reads the full NDJSON and overwrites the JSONL atomically — cheap enough for typical workloads, since most telemetry sessions produce kilobytes to low megabytes.
bun run src/compact-traces.ts --help
--in <path> traces ndjson input (default: data/traces.ndjson)
--out <path> jsonl output (default: data/traces-by-trace.jsonl)
--logs [path] also merge logs with matching traceId (default path: data/logs.ndjson)
--watch re-run whenever inputs changeOTLP LogRecords have optional top-level trace_id / span_id fields; a log record is only attached to a trace bundle if it has a matching trace_id. Claude Code events don't populate this — they use a prompt.id attribute for correlation instead — so compact:all will typically leave logs: [] on Claude Code traces. Producers that do set trace context on log records (many backend SDKs do this automatically inside a span) will get clean merge output.
All optional.
| Var | Default | Description |
|---|---|---|
OTEL_INTERCEPTOR_HTTP_PORT |
4318 |
HTTP (JSON + protobuf) port |
OTEL_INTERCEPTOR_GRPC_PORT |
4317 |
gRPC port |
OTEL_INTERCEPTOR_HTTP_HOST |
0.0.0.0 |
HTTP bind host |
OTEL_INTERCEPTOR_GRPC_HOST |
0.0.0.0 |
gRPC bind host |
OTEL_INTERCEPTOR_DATA_DIR |
./data |
Where NDJSON files are appended |
OTEL_INTERCEPTOR_LOG_REQUESTS |
true |
Log one line per request to stdout |
Claude Code ships with an OpenTelemetry exporter that is off by default. Point it at the interceptor with a few env vars, then run it as usual.
# terminal 1
task start
# terminal 2
export CLAUDE_CODE_ENABLE_TELEMETRY=1
export CLAUDE_CODE_ENHANCED_TELEMETRY_BETA=1 # enable distributed trace spans (beta)
export OTEL_METRICS_EXPORTER=otlp
export OTEL_LOGS_EXPORTER=otlp
export OTEL_TRACES_EXPORTER=otlp
export OTEL_EXPORTER_OTLP_PROTOCOL=grpc
export OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4317
# Flush faster while debugging (defaults are 60s metrics / 5s logs / 5s traces)
export OTEL_METRIC_EXPORT_INTERVAL=2000
export OTEL_LOGS_EXPORT_INTERVAL=1000
export OTEL_TRACES_EXPORT_INTERVAL=1000
claude -p "Reply with exactly the word: pong"After a few seconds, task stats will show non-zero payloads for all three signals.
Same as above but:
export OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf
export OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318- Traces (with
CLAUDE_CODE_ENHANCED_TELEMETRY_BETA=1) — spans likeclaude_code.interaction,claude_code.llm_request, one tool-execution span per tool call, all linked under a singletraceId. - Logs (events) —
claude_code.user_prompt,claude_code.api_request,claude_code.api_error,claude_code.tool_result,claude_code.tool_decision,claude_code.skill_activated,claude_code.plugin_installed,claude_code.mcp_server_connection, etc. - Metrics —
claude_code.session.count,claude_code.token.usage,claude_code.cost.usage,claude_code.lines_of_code.count,claude_code.commit.count,claude_code.pull_request.count,claude_code.code_edit_tool.decision,claude_code.active_time.total.
By default, user prompt text and tool arguments are redacted. To capture them during debugging:
export OTEL_LOG_USER_PROMPTS=1 # include prompt text in claude_code.user_prompt events
export OTEL_LOG_TOOL_DETAILS=1 # include bash commands, file paths, tool args
export OTEL_LOG_TOOL_CONTENT=1 # include raw tool I/O in span events (truncated at 60 KB)
export OTEL_LOG_RAW_API_BODIES=1 # include full Anthropic API request/response bodiesSee the Claude Code monitoring docs for the full list.
Running the interceptor and a single claude -p "hi" with the gRPC config above captures, on a cold session:
traces 1 payloads (claude_code.interaction + claude_code.llm_request spans)
metrics 1 payloads (claude_code.session.count)
logs 1 payloads (user_prompt, api_request, mcp_server_connection, …)
Codex CLI exports telemetry via its config file, not env vars. Add the following to ~/.codex/config.toml:
[otel]
environment = "local-dev"
log_user_prompt = true # opt in to raw prompt text — off by default
# Pick ONE of these exporter setups:
# --- OTLP/gRPC (matches the interceptor on :4317) ---
exporter = "otlp-grpc"
metrics_exporter = "otlp-grpc"
trace_exporter = "otlp-grpc"
[otel.log_exporter]
endpoint = "http://localhost:4317"
[otel.trace_exporter]
endpoint = "http://localhost:4317"
# --- OR OTLP/HTTP (matches the interceptor on :4318) ---
# exporter = "otlp-http"
# metrics_exporter = "otlp-http"
# trace_exporter = "otlp-http"
#
# [otel.log_exporter]
# endpoint = "http://localhost:4318"
# protocol = "binary" # or "json"
#
# [otel.trace_exporter]
# endpoint = "http://localhost:4318"
# protocol = "binary"Then run codex normally and watch task tail:all.
You can also override any of these on a single invocation without editing the config file:
codex -c otel.exporter='"otlp-grpc"' \
-c otel.log_exporter.endpoint='"http://localhost:4317"' \
-c otel.trace_exporter.endpoint='"http://localhost:4317"' \
"explain this repo"See the Codex config reference for the full schema (TLS, static headers, etc.).
Standard OTEL env vars work — point the exporter at the right port/path:
| Protocol | OTEL_EXPORTER_OTLP_PROTOCOL |
OTEL_EXPORTER_OTLP_ENDPOINT |
|---|---|---|
| gRPC | grpc |
http://localhost:4317 |
| HTTP protobuf | http/protobuf |
http://localhost:4318 |
| HTTP JSON | http/json |
http://localhost:4318 |
For per-signal overrides (OTEL_EXPORTER_OTLP_TRACES_ENDPOINT etc.), include the path: http://localhost:4318/v1/traces.
Not logged infromclaude: you still get telemetry — Claude Code emits the session-start metric and anapi_errorevent, which is actually a useful smoke test that the exporter pipeline is wired up.- Nothing in the NDJSON files: lower the export intervals (
OTEL_METRIC_EXPORT_INTERVAL=1000, etc.). Default metrics interval is 60 s, so a short-lived process can exit before the first batch flushes. Claude Code waits for an orderly shutdown so this is usually only an issue with hard kills. - gRPC "14 UNAVAILABLE": the producer couldn't reach port 4317. Check you started the server and that nothing else (another collector) is bound there:
lsof -iTCP:4317 -sTCP:LISTEN. HTTP 415 unsupported content-type: the producer sent something other thanapplication/jsonorapplication/x-protobufto an HTTP endpoint. Usually means a misconfiguredOTEL_EXPORTER_OTLP_PROTOCOL.- The
payload.traceIdlooks like base64, not hex: the normalizer leaves the string unchanged if it can't confidently decode it to the expected byte length. Open an issue with the offending line.
src/
server.ts # main entrypoint — starts both servers, handles shutdown
http.ts # Hono routes for /v1/{traces,metrics,logs}
grpc.ts # @grpc/grpc-js server implementing Export RPCs
protobuf.ts # protobufjs loader, OTLP protobuf decode/encode
json-normalize.ts # base64 -> hex for trace_id/span_id in HTTP/JSON payloads
writer.ts # append-only NDJSON writer per signal
config.ts # env-var parsing
compact-traces.ts # post-processor: group spans (+ optional logs) by traceId
proto/ # vendored opentelemetry-proto (Apache 2.0)
data/ # NDJSON output + compacted JSONL, gitignored
Taskfile.yml
MIT for the interceptor itself. The vendored .proto files under src/proto/opentelemetry/ are Apache 2.0 (see src/proto/LICENSE).
{ "received_at": "2026-04-21T01:26:13.695Z", "transport": "grpc", // "http/json" | "http/protobuf" | "grpc" "signal": "traces", // "traces" | "metrics" | "logs" "source": { "remote_addr": "127.0.0.1:59558", "user_agent": "OTel-OTLP-Exporter-JavaScript/0.215.0 grpc-node-js/1.14.3" }, "payload": { /* decoded ExportTraceServiceRequest — same shape as OTLP/JSON spec */ } }