Receiving
Aftertrace receives standard OpenTelemetry (OTLP) on two ports. Each export it receives is flattened into one row per span, log record or metric point, redacted, and written to the store in one transaction.
Point at a part to read it. O sends an export over the size limit. I runs an import.
The two receivers
| gRPC | HTTP | |
|---|---|---|
| Address | 127.0.0.1:4317 |
127.0.0.1:4318 |
| What it takes | OTLP TraceService, LogsService and MetricsService Export calls |
POST /v1/traces, POST /v1/logs, POST /v1/metrics |
| Body | protobuf | protobuf, or JSON (see below) |
| Size limit | 32 MB per message | 32 MB per request body |
| Change it with | --grpc-port, --listen |
--http-port, --listen |
Both listen on 127.0.0.1 by default, so only programs on your machine can reach them. --listen 0.0.0.0 accepts exports from other machines (team mode); see Behind a collector. No headers or tokens are needed.
What happens to one request
- Arrive. An export reaches one of the ports. On HTTP, anything but
POSTgets405 Method Not Allowed. A client has 10 seconds to send its headers. - Size check. A body over 32 MB is refused before it is decoded. HTTP answers
400withhttp: request body too large; gRPC answersRESOURCE_EXHAUSTED. Nothing from it is stored. - Decode. gRPC is always protobuf. On HTTP, a
Content-Typecontainingjsonmeans OTLP/JSON, anything else means protobuf. With noContent-Type, a body that starts with{is read as JSON. OTLP/JSON trace and span ids are hex strings, and they are kept as the same hex in the store. - Flatten. An export is nested: resources (one per process, with
service.nameand the like) hold scopes, and scopes hold the records. Each record becomes one row, with its resource's attributes copied onto it. The session id comes from the record'ssession.id, or its resource's when the record has none. - Redact. Before any row exists, identity keys are deleted from the resource, the record and every span event. Content keys are deleted too unless the server runs with
--store-content. See Redaction. - Write. All the rows from one export are written in one transaction: all of them or none. The sender then gets
200(HTTP) or an emptyExportResponse(gRPC). If the write fails, HTTP answers400with the error and gRPC returns it.
A span sent twice replaces itself rather than counting twice. Log records and metric points have no natural key, so a resend adds them again. See The store.
What each signal becomes
| Signal | Endpoint | Rows | Notes |
|---|---|---|---|
| Traces | /v1/traces |
spans, span_events |
One spans row per span, keyed by trace id and span id. Each span event becomes a span_events row. Error status sets is_error. |
| Logs | /v1/logs |
events |
The event name is the event.name attribute, else the log body, with a leading claude_code. removed. prompt.id, request_id and tool_use_id get their own columns. |
| Metrics | /v1/metrics |
metric_points |
Sum and gauge points only. Histogram and summary points are not stored. Integer values are stored as numbers like any other. |
Pointing a source at it
Claude Code, over gRPC (the full block, with the trace beta, is in Connect Claude Code):
export CLAUDE_CODE_ENABLE_TELEMETRY=1
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://127.0.0.1:4317
For HTTP, use OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf (or http/json) and OTEL_EXPORTER_OTLP_ENDPOINT=http://127.0.0.1:4318. The SDK adds /v1/traces, /v1/logs and /v1/metrics itself, so leave them out of the endpoint. Any other OpenTelemetry SDK works the same way; see Apps and agents.
Try it by hand
One log record as OTLP/JSON:
curl -i http://127.0.0.1:4318/v1/logs \
-H 'Content-Type: application/json' \
-d '{"resourceLogs":[{"resource":{"attributes":[
{"key":"service.name","value":{"stringValue":"curl-test"}}]},
"scopeLogs":[{"logRecords":[{
"timeUnixNano":"1760000000000000000",
"body":{"stringValue":"hello"},
"attributes":[
{"key":"session.id","value":{"stringValue":"demo-1"}},
{"key":"user.email","value":{"stringValue":"a@b.c"}}]}]}]}]}'
It answers 200, and the store gets one events row: name hello, session demo-1, attributes {"session.id": "demo-1"}, resource {"service.name": "curl-test"}. user.email is gone: it is an identity key.
Importing files
aftertrace import <dir> reads OTLP/JSON from files instead of the network. It looks for traces.jsonl, logs.jsonl and metrics.jsonl in that directory (each one optional), one export request per line. Each line goes through the same flatten, redact and write steps as a network export. A line that is not valid JSON stops the import with its file and line number. --db and --store-content work as they do for serve. When it finishes it prints how many rows it added and runs the rules once.
aftertrace demo uses the same path: it writes synthetic .jsonl files to a temporary directory and imports them.
When nothing arrives
- Nothing at all. Check the protocol and the port agree:
grpcwith4317,http/protobuforhttp/jsonwith4318. Claude Code has no default protocol, so set one. - Metrics and events but no sessions. Claude Code only sends spans with its trace beta on; see Connect Claude Code.
- From another machine. The receivers only listen on
127.0.0.1unless you pass--listen.
More in Troubleshooting.
Next: Redaction, what is dropped on the way in.