Skip to content

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

Terminal window
llm-usage events --format otlp-traces --since 2026-06-01 > traces.jsonl
llm-usage events --format otlp-metrics --since 2026-06-01 > metrics.jsonl

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

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-code for Claude Code, the source id for the others (codex, pi, …). Claude Code reports claude-code from its own telemetry, so a backfill groups with its live data.
  • The scope is llm-usage-metrics. Its llm_usage.semconv_genai.commit attribute names the semantic-conventions-genai commit the attribute names were checked against, and schemaUrl is the schema URL that commit declares.
  • Lines follow the order of each session’s first usage event, the order --format jsonl prints them in.
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 call
  • invoke_agent carries gen_ai.operation.name, gen_ai.agent.name (the same value as service.name), gen_ai.conversation.id, and the session’s sums of gen_ai.usage.input_tokens and gen_ai.usage.output_tokens, as the convention recommends for agent spans. It has no cache attributes: the convention asks consumers to sum those from the chat spans. Because the root repeats the sums of its chat spans, a span-metrics connector or query that adds gen_ai.usage.* over every span counts each token twice; filter on gen_ai.operation.name = chat before summing.
  • chat spans carry the usage attributes in the mapping table. The span is named chat when the model is unknown.
  • execute_tool spans carry the tool attributes in the mapping table, plus gen_ai.agent.name. They come from the tool calls the tools report 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.

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.

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.

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.

  • Message text, prompts, tool arguments, tool output and session titles are never exported.
  • Repository paths are left out by default. --otel-include-paths adds llm_usage.repo_root to chat spans. 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-paths is 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.

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.name is 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. For google the helpers pick gcp.gemini; the source logs do not say which Google endpoint served the call. Other Google and Azure ids (google-gemini-cli or a custom azure-east, for example) pass through too, since they do not name a well-known endpoint.
  • gen_ai.response.model is the model name lowercased, not the exact string the provider returned. chat spans do not set gen_ai.request.model, since the logs record only the model that answered, so the span name uses the response model.
  • gen_ai.conversation.id is 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).

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

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.

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 within storage.tsdb.out_of_order_time_window (its guide suggests 30m), and converts DELTA temporality only behind the experimental otlp-deltatocumulative feature. 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.

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:

Terminal window
day=$(date -d yesterday +%F) # on macOS: date -v-1d +%F
llm-usage events --format otlp-metrics --since "$day" --until "$day" --otlp-endpoint https://otlp.example.com

If a push fails partway, the error says how many batches went through. Those were stored, so sending the whole range again counts them twice.

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]
Terminal window
otelcol-contrib --config collector.yaml

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

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

Terminal window
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/traces or /v1/metrics is appended, as with OTEL_EXPORTER_OTLP_ENDPOINT. A URL whose path already ends with that suffix is used as is. It must start with http: or https: 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, 503 or 504 is retried, as the OTLP/HTTP spec asks: after the Retry-After delay 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. A Retry-After over 60 seconds fails the push at once instead of waiting. Redirects are not followed: a 3xx answer 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.
  • stdout stays empty. stderr gets 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_HEADERS in the OpenTelemetry format, key=value pairs separated by commas with percent-encoded values. Names are case-insensitive: of two entries that differ only in case, the later one wins, and a content-type entry is replaced by application/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 an Authorization value, such as Bearer, does not count.
Terminal window
export OTEL_EXPORTER_OTLP_HEADERS="Authorization=Bearer%20<token>"
llm-usage events --format otlp-metrics --otlp-endpoint https://otlp.example.com

The Old timestamps limits apply to a push the same way, and so does Sending metrics twice.