Claude client telemetry ingest (OTLP)¶
Editions: OSS, Cloud, Enterprise.
Claude Code, Claude Desktop and Cowork export OpenTelemetry metrics and log events (and, when enabled, traces) over OTLP/HTTP. Preloop can receive those exports and turn them into usage rows, so you see:
- cost and token counts for Claude traffic that never went through the Preloop model gateway;
- per-request telemetry (session id, prompt id, client, reported cost) on the gateway's own usage rows, matched by request;
- Desktop and terminal sessions grouped with their gateway requests.
This page is about telemetry flowing into Preloop. For Preloop's own traces and metrics flowing out to your collector, see OTLP export.
Endpoints¶
| Signal | Path | Stored |
|---|---|---|
| Metrics | POST /api/v1/telemetry/otlp/v1/metrics |
cost and token counters |
| Logs | POST /api/v1/telemetry/otlp/v1/logs |
api_request, api_error |
| Traces | POST /api/v1/telemetry/otlp/v1/traces |
acknowledged, not stored |
Use <your Preloop URL>/api/v1/telemetry/otlp as the exporter base URL; the
standard per-signal paths follow it.
- Content types:
application/x-protobuf(the default for Desktop, Cowork and the apps gateway) andapplication/json.Content-Encoding: gzipis accepted. - Limits: 4 MiB per request after decompression, 10,000 data points or log records per request, attribute values cut to 1 KiB, at most 64 attributes read per record.
- Responses follow OTLP/HTTP:
200with an emptyExport*ServiceResponsein the request's encoding. When records were dropped (outside the allowlist below, or over the per-request limit) the response carriespartial_successwith therejected_*count.400undecodable body,401missing or invalid key,403key without the scope,413too large,415other content types,429withRetry-Afterwhen the per-key rate limit is hit (600 requests per minute by default,PRELOOP_OTLP_INGEST_RATE_LIMITto change it).
1. Create an ingest key¶
An account admin creates an API key with the single scope
telemetry:ingest:
curl -s -X POST "$PRELOOP_URL/api/v1/auth/api-keys" \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d '{"name": "claude telemetry ingest", "scopes": ["telemetry:ingest"]}'
Only admins can grant the scope. A key whose only scope is
telemetry:ingest can post OTLP exports and nothing else: every other REST
route, the MCP endpoint, the model gateway and console channels refuse it.
Send it as Authorization: Bearer <key> or x-api-key: <key>.
2a. Behind a Claude apps gateway¶
Add Preloop as a telemetry.forward_to destination in the gateway config.
The gateway relays each export verbatim, stamped with the signed-in user's
identity, and pushes the telemetry settings to connected clients:
telemetry:
forward_to:
- url: https://preloop.example.com/api/v1/telemetry/otlp
headers:
Authorization: Bearer ${PRELOOP_TELEMETRY_KEY}
metrics: true
logs: true # needed for per-request matching
traces: false # accepted but not stored
The gateway's default is metrics only. With metrics only, Preloop builds
hourly aggregates per session and model (see below); turn logs on to get
per-request rows and enrichment.
2b. Direct from Claude Code or Desktop¶
Without a gateway, set the standard exporter variables (in managed settings for a fleet):
export CLAUDE_CODE_ENABLE_TELEMETRY=1
export OTEL_METRICS_EXPORTER=otlp
export OTEL_LOGS_EXPORTER=otlp
export OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf
export OTEL_EXPORTER_OTLP_ENDPOINT=https://preloop.example.com/api/v1/telemetry/otlp
export OTEL_EXPORTER_OTLP_HEADERS="Authorization=Bearer <ingest key>"
http/json works too. gRPC is not supported.
What is stored, and what is dropped¶
Preloop keeps an allowlist and drops everything else before it reaches the database. Dropped records are only counted.
Log events. Only claude_code.api_request and claude_code.api_error.
Of their attributes, only: request_id, client_request_id, session.id,
prompt.id, model, input_tokens, output_tokens, cache_read_tokens,
cache_creation_tokens, cost_usd, duration_ms, status_code,
user.email, user.id, enduser.id, enduser.sub, user.groups,
identity.source, terminal.type, service.name, service.version,
organization.id. Every other event (user_prompt, tool_result,
tool_decision, api_request_body, api_response_body and the rest) is
dropped, as is any other attribute on an allowed event, including a prompt
text attribute.
Metrics. Only claude_code.cost.usage and claude_code.token.usage
(with their type and model) and claude_code.session.count. Delta and
cumulative temporality are both handled; cumulative series are turned into
deltas with per-series state that expires after 24 hours.
Traces. Acknowledged so exporters do not error, counted, not stored.
Preloop never stores prompts, tool inputs, commands, file paths or traces from this endpoint.
Logs and traces are sensitive at the source
Claude Code log events and traces can carry full Bash commands, tool
inputs and file paths (and prompt text when OTEL_LOG_USER_PROMPTS is
set). Preloop drops them on arrival, but they still leave the developer
machine and pass through the apps gateway. Enable logs and traces only
toward destinations whose access controls and retention fit that data.
How usage rows are built (never counted twice)¶
- An
api_requestevent that matches a gateway request enriches that gateway usage row (meta_data.telemetry: session id, prompt id, client, identity and the client's reportedcost_usd) and creates nothing. Matching tries, in order:- the client's
x-client-request-id, which the Preloop Anthropic gateway records on its usage rows (meta_data.client_request_id); upstream_request_id == request_id;- a signature: same account, completion time within 60 seconds, same
model (a dated id on one side is fine), same output token count, same
person (email or IdP subject, or a gateway row that names no person),
and a gateway row not enriched yet. The closest in time wins.
meta_data.telemetry.matchsays which rule matched.
- the client's
Why the third rule exists: the request_id a client reports comes from
the request-id response header, which Preloop does not relay, and
behind a Claude apps gateway (2.1.288 in our harness) the relayed events
carry neither request_id nor client_request_id, and the gateway does
not forward x-client-request-id or x-claude-code-session-id upstream.
- An unmatched api_request creates one usage row with
usage_source = imported, cost_source = telemetry_estimate,
meta_data.source = otlp, the reported tokens and
estimated_cost = cost_usd.
- A gateway request recorded later for the same request removes the
telemetry row in the same transaction and keeps its telemetry on the
gateway row. One request, one row.
- Metrics create rows only when the session has neither log events nor
gateway rows, and the person (email or IdP subject) has no gateway rows
in that UTC hour: one aggregate per (session, model, UTC hour), with
meta_data.aggregate = true. The person rule exists because behind an
apps gateway the gateway rows carry no client session id, so a session
cannot be matched; Preloop prefers an undercount to a double count. If
the session's log events arrive later, its aggregates are removed in
favour of the per-request rows. Logs and metrics of one session are
serialized, so concurrent batches cannot both write.
- Retries are no-ops. Every stored record has a sha256 dedup key over
account, signal, resource attributes, scope, record time and request id
(or metric name and series attributes), enforced by a unique index. Dedup
keys are kept 7 days.
Rows created from telemetry follow the normal usage retention policy.
Identity and sessions¶
- Desktop and Cowork send
enduser.sub; terminal sessions signed in through the apps gateway send the subject asuser.idwithidentity.source: gateway-oidc.user.idis used only with that marker (in our 2.1.288 harness run the marker was absent, so terminal events resolved no subject; their email still matched gateway rows). Together withuser.emailit resolves a gateway subject keyed to the ingest key, the same record the trusted upstream gateway path uses. The email links a subject to an existing member only; no users or members are created. Desktop'suser.idis an anonymous install id and is never used. session.idmaps onto the runtime session the gateway recorded for the same Claude Code session (x-claude-code-session-id), so telemetry rows and gateway rows of one session show up together in the existing sessions and cost views. A session the gateway never saw keeps its id on the row (conversation_id) without a runtime session. Behind an apps gateway the session header does not reach Preloop, so gateway rows there are grouped per person and day instead and telemetry rows join them only through the request match above.