Skip to content

context-labs/otel-interceptor

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

3 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

otel-interceptor

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

What's supported

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.

Install & run

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

You 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

Output format

Each incoming export is appended as a single line to data/<signal>.ndjson. One payload = one line. The structure of every line:

{
  "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 */ }
}

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.

Tasks

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.

Compacted traces (one line per trace)

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.

One-shot

task compact:traces       # traces only
task compact:all          # also attach logs whose top-level traceId matches

Continuous (watch mode)

task compact:watch        # traces only
task compact:watch:all    # also merging matching logs

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

Raw CLI

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 change

Log correlation caveat

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

Environment variables (server-side)

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

Collecting OTel from Claude Code

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.

gRPC (recommended)

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

HTTP/protobuf

Same as above but:

export OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf
export OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318

What Claude Code emits

  • Traces (with CLAUDE_CODE_ENHANCED_TELEMETRY_BETA=1) — spans like claude_code.interaction, claude_code.llm_request, one tool-execution span per tool call, all linked under a single traceId.
  • 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.
  • Metricsclaude_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 bodies

See the Claude Code monitoring docs for the full list.

Verified end-to-end

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, …)

Collecting OTel from OpenAI Codex

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


Usage with any other OTel producer

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.

Troubleshooting

  • Not logged in from claude: you still get telemetry — Claude Code emits the session-start metric and an api_error event, 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 than application/json or application/x-protobuf to an HTTP endpoint. Usually means a misconfigured OTEL_EXPORTER_OTLP_PROTOCOL.
  • The payload.traceId looks 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.

Layout

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

License

MIT for the interceptor itself. The vendored .proto files under src/proto/opentelemetry/ are Apache 2.0 (see src/proto/LICENSE).

About

No description, website, or topics provided.

Resources

License

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors