OpenTelemetry Export
llm-usage events --format otlp-traces turns your session history into OpenTelemetry traces: one trace per session, written to stdout as OTLP/JSON Lines. --format otlp-metrics writes daily token, cost and tool duration metrics instead (Metrics). The output is the OTLP file format, so an OpenTelemetry Collector can read the file and send it to Jaeger, Tempo, Honeycomb or any other backend that takes OTLP. Nothing is sent over the network unless you pass --otlp-endpoint (Push to a receiver).
llm-usage events --format otlp-traces --since 2026-06-01 > traces.jsonlllm-usage events --format otlp-metrics --since 2026-06-01 > metrics.jsonlThe filters work as they do for --format jsonl: --source, --provider, --model, --repo, --machine, --since, --until, --timezone, pricing flags, --cost-mode and --no-history. See Events export.
Output
Section titled “Output”Each line is one TracesData object holding one session. The encoding follows the OTLP JSON rules: trace and span ids are hex strings, timestamps and integer attributes are decimal strings ("startTimeUnixNano": "1790845203500000000", {"intValue": "1200"}), and span kinds and status codes are integers.
- The resource has
service.name:claude-codefor Claude Code, the source id for the others (codex,pi, …). Claude Code reportsclaude-codefrom its own telemetry, so a backfill groups with its live data. - The scope is
llm-usage-metrics. Itsllm_usage.semconv_genai.commitattribute names the semantic-conventions-genai commit the attribute names were checked against, andschemaUrlis the schema URL that commit declares. - Lines follow the order of each session’s first usage event, the order
--format jsonlprints them in.
Span tree
Section titled “Span tree”invoke_agent {agent} INTERNAL earliest to latest event of the session├── chat {model} CLIENT one per usage event, zero-length└── execute_tool {tool name} INTERNAL one per tool callinvoke_agentcarriesgen_ai.operation.name,gen_ai.agent.name(the same value asservice.name),gen_ai.conversation.id, and the session’s sums ofgen_ai.usage.input_tokensandgen_ai.usage.output_tokens, as the convention recommends for agent spans. It has no cache attributes: the convention asks consumers to sum those from thechatspans. Because the root repeats the sums of itschatspans, a span-metrics connector or query that addsgen_ai.usage.*over every span counts each token twice; filter ongen_ai.operation.name=chatbefore summing.chatspans carry the usage attributes in the mapping table. The span is namedchatwhen the model is unknown.execute_toolspans carry the tool attributes in the mapping table, plusgen_ai.agent.name. They come from the tool calls thetoolsreport counts (Claude Code, codex and pi), deduplicated the same way. They run from the call to its result, or for the logged duration when the source records one instead. A call with no result in the log is zero-length.- A session appears when it has usage in the export. Its tool calls come with it; tool calls of sessions the filters leave out do not.
Zero-length spans
Section titled “Zero-length spans”Session logs record when a response arrived, not how long the request took. Every chat span therefore starts and ends at the event’s timestamp. Latency views, duration histograms and span-metrics generated from these traces show zero for inference. The export emits no gen_ai.client.inference.duration; tool calls get gen_ai.execute_tool.duration where the log records when they ended.
Stable ids
Section titled “Stable ids”Ids are derived from the data, not generated:
traceId: the first 16 bytes of SHA-256 over the source and session id.spanId: the first 8 bytes of SHA-256 over the trace id, the span kind and a key. A tool call with an id is keyed by that id. A usage event is keyed by its timestamp and model, a tool call without an id by its timestamp and tool name, each with a counter that tells apart entries sharing those values. The root span’s key is the session alone.
The same input gives the same bytes. A backend that deduplicates by trace and span id keeps one copy when you send the same export twice, and a span keeps its id in any date window that includes it. A chat span is keyed on its timestamp, model and provider, so a filter keeps its id unless it drops another event of the same session with all three equal; --repo can do that when one session’s requests at the same instant are recorded under two projects. The root span does not: each export of a session writes it with the times and token sums of the events in that export, so exporting a session in two windows writes its root span twice with different values.
Errors
Section titled “Errors”A tool call whose result is an error sets the span status to ERROR (code 2) and error.type to _OTHER, the value the convention reserves for errors with no finer class: the logs say a call failed, not why. A call without a recognised result keeps status unset and gets llm_usage.tool.status = unknown.
Privacy
Section titled “Privacy”- Message text, prompts, tool arguments, tool output and session titles are never exported.
- Repository paths are left out by default.
--otel-include-pathsaddsllm_usage.repo_roottochatspans. The export has no working directory or git branch attributes. - Session ids, model names, tool names and token counts are always included. A session id that holds a path is written as
sha256:and a hash of it unless--otel-include-pathsis given. Qwen names a chat without a session id after its project directory, which is the working directory with every character other than a letter or digit turned into-(-home-me-project-<file>); an id with a/or\gets the same treatment. The trace id comes from the original id either way, so the two exports share trace ids.
Mapping
Section titled “Mapping”Names follow commit f49da22 of open-telemetry/semantic-conventions-genai (schema URL https://opentelemetry.io/schemas/gen-ai-dev/1.42.0-dev). The conventions have no release, so names can change when the pin moves. The experimental @ayagmar/agent-sessions/otel helpers implement these tables.
Usage event → chat span:
| export field | OTel mapping |
|---|---|
| (every event) | gen_ai.operation.name = chat |
provider |
gen_ai.provider.name; google → gcp.gemini, moonshot → moonshot_ai, xai → x_ai, amazon-bedrock* → aws.bedrock, google-vertex* → gcp.vertex_ai, azure-openai* → azure.ai.openai, others unchanged; omitted when unknown |
model |
gen_ai.response.model, and the span name chat {model} |
sessionId |
gen_ai.conversation.id |
inputTokens + cacheReadTokens + cacheWriteTokens |
gen_ai.usage.input_tokens (the convention counts cached tokens as input) |
outputTokens |
gen_ai.usage.output_tokens (already includes reasoning) |
reasoningTokens |
gen_ai.usage.reasoning.output_tokens, left out when zero |
cacheReadTokens |
gen_ai.usage.cache_read.input_tokens, left out when zero |
cacheWriteTokens |
gen_ai.usage.cache_write.input_tokens, left out when zero |
costUsd, costMode |
no convention yet; llm_usage.cost_usd (always a double), llm_usage.cost_mode |
source |
llm_usage.source |
machine |
llm_usage.machine, for events from other machines |
repoRoot |
llm_usage.repo_root, only with --otel-include-paths |
timestamp |
startTimeUnixNano = endTimeUnixNano |
Tool call → execute_tool span:
| tool call field | OTel mapping |
|---|---|
| (every call) | gen_ai.operation.name = execute_tool |
name |
gen_ai.tool.name, and the span name execute_tool {name} |
toolCallId |
gen_ai.tool.call.id: the id the model or provider gave the call (Claude Code tool_use id, codex call_id, pi tool call id); left out when the log has none |
sessionId |
gen_ai.conversation.id |
status |
error: status ERROR and error.type = _OTHER; unknown: llm_usage.tool.status = unknown; ok: status unset |
messageId |
llm_usage.message_id, the assistant message that made the call |
timestamp, completedAt, durationMs |
start at timestamp; end at completedAt, else timestamp + durationMs, else zero-length |
source |
llm_usage.source, and gen_ai.agent.name (the same value as service.name) |
Deviations from the convention:
gen_ai.provider.nameis required, but it is left out when the provider is unknown rather than guessed.- A provider with no well-known value (
github, for example) is passed through as a custom value. Forgooglethe helpers pickgcp.gemini; the source logs do not say which Google endpoint served the call. Other Google and Azure ids (google-gemini-clior a customazure-east, for example) pass through too, since they do not name a well-known endpoint. gen_ai.response.modelis the model name lowercased, not the exact string the provider returned.chatspans do not setgen_ai.request.model, since the logs record only the model that answered, so the span name uses the response model.gen_ai.conversation.idis set even where a source has no session id and the file name stands in for it (qwen, openclaw), which the convention discourages. A qwen id built from the project directory is hashed (Privacy).
Metrics
Section titled “Metrics”--format otlp-metrics writes one MetricsData object per agent and day, ordered by day and then by agent. A day is a calendar day in --timezone, the same buckets daily uses, so the points of one day add up to that day’s rows in daily --json for the same filters. Resource and scope are the same as for traces.
Each line holds monotonic Sums and histograms, all with DELTA temporality ("aggregationTemporality": 1). Every data point covers one day: startTimeUnixNano is local midnight and timeUnixNano the next local midnight, so a day with a DST change is 23 or 25 hours long. The day that is still going when you run the export ends at the run time instead, so no point is stamped in the future.
| metric | unit | value |
|---|---|---|
gen_ai.client.inference.usage.input_tokens |
{token} |
inputTokens + cacheReadTokens + cacheWriteTokens |
gen_ai.client.inference.usage.output_tokens |
{token} |
outputTokens (includes reasoning) |
gen_ai.client.inference.usage.cache_read.input_tokens |
{token} |
cacheReadTokens |
gen_ai.client.inference.usage.cache_write.input_tokens |
{token} |
cacheWriteTokens |
gen_ai.client.inference.usage.reasoning.output_tokens |
{token} |
reasoningTokens |
llm_usage.cost_usd |
USD |
costUsd, logged or estimated; no convention yet |
A metric has one point per provider, model and machine that had usage that day. The points carry gen_ai.operation.name = chat, gen_ai.provider.name and gen_ai.response.model (mapped as for chat spans, and left out when unknown), gen_ai.token.modality = unknown (the logs do not split tokens by modality), and llm_usage.machine for usage from other machines. Token points are written even when the count is zero. Usage without a price adds nothing to llm_usage.cost_usd; when none of a point’s usage has a price, that point has no cost value. Paths are never on metric points, so --otel-include-paths is rejected with otlp-metrics.
The two per-inference histograms describe how tokens spread across requests, for percentiles and outliers; the convention says not to sum them for totals. Each has one point per provider, model and machine with count, sum, min, max and bucket counts, over the bounds the convention advises (1, 4, 16, … 67108864 tokens). They carry the same attributes as the counters except gen_ai.token.modality, which the convention leaves off histograms.
| metric | unit | value per inference |
|---|---|---|
gen_ai.client.inference.operation.input_tokens |
{token} |
inputTokens + cacheReadTokens + cacheWriteTokens |
gen_ai.client.inference.operation.output_tokens |
{token} |
outputTokens |
Tool durations
Section titled “Tool durations”gen_ai.execute_tool.duration (unit s) is a histogram of tool call durations, one point per tool name and outcome, with gen_ai.tool.name, gen_ai.agent.name and, for failed calls, error.type = _OTHER. The bucket bounds are the advised 0.01 s to 81.92 s. A call counts on the day it started. It reads the same tool calls as the execute_tool spans and keeps only the ones with a measured duration: a result with its own timestamp, or a logged duration. Calls with no result, and calls whose result shares the call’s record (codex web searches), are left out rather than counted as zero. A day whose only activity for an agent is tool calls gets a line holding just this metric.
Older instrumentations and backends use gen_ai.client.token.usage with a gen_ai.token.type attribute. The pinned conventions replaced it with the counters above, so the export does not write it.
Old timestamps
Section titled “Old timestamps”Every point is stamped with its day, so most of a backfill is days or months old, and metric backends usually reject old samples by default:
- Prometheus takes OTLP only with
--web.enable-otlp-receiver, accepts out-of-order samples only withinstorage.tsdb.out_of_order_time_window(its guide suggests30m), and converts DELTA temporality only behind the experimentalotlp-deltatocumulativefeature. Weeks-old samples are rejected by default. See the Prometheus OpenTelemetry guide. - Datadog treats points more than an hour old as historical and drops them unless Historical Metrics Ingestion is enabled for that metric.
An export can succeed and still show nothing in such a backend. Trace backends generally accept old spans, which makes traces the safer backfill.
Sending metrics twice
Section titled “Sending metrics twice”A DELTA point says “this much happened between these two times”, and a backend that stores sums adds every point it receives. Sending the same day twice counts it twice: a re-run of the same range, a cron job whose windows overlap, or a push of today followed by a later push of today (both points start at midnight, and the second one includes the first). Traces do not have this problem, since their ids are stable.
Send each finished day once. A daily job that pushes only yesterday does that:
day=$(date -d yesterday +%F) # on macOS: date -v-1d +%Fllm-usage events --format otlp-metrics --since "$day" --until "$day" --otlp-endpoint https://otlp.example.comIf a push fails partway, the error says how many batches went through. Those were stored, so sending the whole range again counts them twice.
Send it to a backend
Section titled “Send it to a backend”The Collector’s otlpjsonfile receiver reads the file, and the otlphttp exporter forwards it. Both ship in the otelcol-contrib distribution. A file holds one signal, so traces and metrics go in separate files; for metrics, include ./metrics.jsonl and name the pipeline metrics.
receivers: otlpjsonfile: include: - ./traces.jsonl start_at: beginning
exporters: otlphttp: endpoint: http://localhost:4318
service: pipelines: traces: receivers: [otlpjsonfile] exporters: [otlphttp]otelcol-contrib --config collector.yamlTrace backends generally accept spans with old timestamps, but some only make them searchable within a retention or block window. Check your backend’s limits before backfilling months of history. If an agent also sends its own live telemetry to the same backend, the same request can show up twice: pick one source per request.
Push to a receiver
Section titled “Push to a receiver”--otlp-endpoint <url> sends the export to an OTLP/HTTP receiver instead of printing it: a Collector, Jaeger, or a backend that takes OTLP over HTTP with JSON bodies.
llm-usage events --format otlp-traces --since 2026-06-01 --otlp-endpoint http://127.0.0.1:4318- The URL is the receiver’s base:
/v1/tracesor/v1/metricsis appended, as withOTEL_EXPORTER_OTLP_ENDPOINT. A URL whose path already ends with that suffix is used as is. It must start withhttp:orhttps:and must not hold a user name or password. A query string is sent but left out of the summary line and errors. - The request bodies hold the same resources the file export holds, in the same order. Whole lines are grouped into requests of up to 4 MiB. A session larger than that is split across requests: each piece holds some of its spans under the same resource, the root span goes in the first, and every span keeps its ids, so the backend puts the trace back together. A single metrics line (one agent and day) is never split.
- Each request waits 10 seconds for an answer. A
429,502,503or504is retried, as the OTLP/HTTP spec asks: after theRetry-Afterdelay when the receiver sends one, or after 1, 2, then 4 seconds when it sends none or one that cannot be read, for at most 4 attempts. ARetry-Afterover 60 seconds fails the push at once instead of waiting. Redirects are not followed: a3xxanswer is a failure, so pass the URL the receiver redirects to. Any other failure stops the push and exits with status 1; the error names the HTTP status, the receiver’s message, and how many batches went through before it. stdoutstays empty.stderrgets one line with the spans or points sent, the number of requests, and any items the receiver reported as rejected in a partial success.- Headers come from
OTEL_EXPORTER_OTLP_HEADERSin the OpenTelemetry format,key=valuepairs separated by commas with percent-encoded values. Names are case-insensitive: of two entries that differ only in case, the later one wins, and acontent-typeentry is replaced byapplication/json. Headers are never taken from the command line and never printed. The receiver’s status text, error message and partial-success text never print a header value: a value or a word in one of 8 characters or more is printed as[redacted], and text that holds a shorter one is left out (the status text) or replaced by[receiver message withheld: it may contain a header value]. The scheme word of anAuthorizationvalue, such asBearer, does not count.
export OTEL_EXPORTER_OTLP_HEADERS="Authorization=Bearer%20<token>"llm-usage events --format otlp-metrics --otlp-endpoint https://otlp.example.comThe Old timestamps limits apply to a push the same way, and so does Sending metrics twice.