Export spans to your collector
Turn on OTLP export, point it at a collector you run, and see exactly what arrives — including the two things that do not.
On this page
By the end of this page TokenTriage is posting one span per costed request to your own OpenTelemetry collector, and you have looked at a payload rather than trusting a description of one.
Export is off out of the box, and it dials nothing until you configure an endpoint. The destination is a collector you name; there is no vendor endpoint and no default.
1. Configure the exporter
export:
otlp:
enabled: true # off by default
endpoint: http://127.0.0.1:4318/v1/traces # your collector's OTLP/HTTP traces path
headers_env: TT_OTLP_HEADERS # the NAME of an env var, not its value
The endpoint is an OTLP/HTTP JSON traces URL, including the path. The wire format is OTLP/JSON over plain HTTP POST, so any collector that accepts OTLP/HTTP works and no gRPC port is involved.
headers_env names an environment variable. The variable holds comma-separated key=value
headers and is read at send time rather than at start-up, so the credential is not held in the
config file or in the process’s own state:
$ export TT_OTLP_HEADERS='authorization=Bearer <token>'
Leave headers_env unset for a collector that needs no auth.
2. The flag that is not enough
export.otlp.enabled: true on its own does nothing. The exporter is constructed alongside the
dashboard, so it also needs the dashboard switched on:
dashboard:
enabled: true # required for span export in this build
export:
otlp:
enabled: true
endpoint: http://127.0.0.1:4318/v1/traces
Nothing warns you about the missing half. tokentriage validate accepts the config, the proxy
starts, traffic is served and costed, and no span is ever posted. Verified against the shipped
build: with dashboard.enabled: false and export configured, a local collector received zero
bytes across 65 requests and tokentriage_otlp_dropped_total was absent from /metrics. With the
dashboard on and nothing else changed, the same traffic produced a payload.
3. Send traffic and look at a payload
With a collector listening, drive a request that carries a trace id and read what arrives. One span from a lab run, trimmed to its attributes:
{
"traceId": "f02e9edafef5685de91daa305f511d92",
"spanId": "5af0107423594140",
"parentSpanId": "869f9ecc2e818034",
"name": "gpt-4o-mini",
"kind": 3,
"attributes": [
{ "key": "gen_ai.request.model", "value": { "stringValue": "gpt-4o-mini" } },
{ "key": "gen_ai.response.model", "value": { "stringValue": "gpt-4o-mini" } },
{ "key": "gen_ai.provider.name", "value": { "stringValue": "openai" } },
{ "key": "gen_ai.usage.input_tokens", "value": { "intValue": "768" } },
{ "key": "gen_ai.usage.output_tokens", "value": { "intValue": "512" } },
{ "key": "gen_ai.usage.cache_read.input_tokens", "value": { "intValue": "256" } },
{ "key": "gen_ai.cost.amount", "value": { "doubleValue": 0.0004416 } },
{ "key": "gen_ai.cost.currency", "value": { "stringValue": "USD" } },
{ "key": "gen_ai.cost.pricing_source", "value": { "stringValue": "litellm@2026-07-14#179affb" } },
{ "key": "tokentriage.cost.usd", "value": { "doubleValue": 0.0004416 } },
{ "key": "tokentriage.cost.would_usd.frontier", "value": { "doubleValue": 0.00736 } },
{ "key": "tokentriage.trace.agent", "value": { "stringValue": "researcher" } },
{ "key": "tokentriage.dim.tenant", "value": { "stringValue": "acme-corp" } }
]
}
That is one span out of a batch, with about a third of its attributes shown; the full set is in
the attribute mapping. The upstream was a mock with
fixed token counts, so $0.0004416 is arithmetic over those counts rather than a bill. kind: 3
is SPAN_KIND_CLIENT, and the batch’s resource carries a single attribute,
service.name: tokentriage.
Spans are batched: a POST goes out every 128 rows or every two seconds, whichever comes first, with a ten-second timeout and two retries on a transient failure.
4. Know what does not arrive
Two categories of row never reach your collector, and neither is a bug in your setup.
Rows with no trace id are skipped. The OTLP trace id is derived from the row’s own
trace.trace_id, so a request that carried no x-tt-trace-id has nothing to derive from. In the
lab run behind this page, 66 requests produced 5 spans, because only 5 carried identity. If your
collector shows a small fraction of your traffic, that fraction is your instrumented share, and
putting identity on the wire is the lever.
Rows dropped on a full export queue are counted rather than shipped. The queue holds 4,096
rows and the exporter never blocks the ledger writer, so a slow or unreachable collector costs you
spans and costs your traffic nothing. The count is tokentriage_otlp_dropped_total.
A limit worth knowing before you build on the trace view
The parent edge does not survive the export in this build. A span’s own id is derived from the
row’s request id, while its parent id is derived from x-tt-parent-id, which is a step id. The
two are hashed from different id spaces, so a child’s parentSpanId names a span that does not
exist.
You can check it on the payload above. parentSpanId is 869f9ecc2e818034, which is the derived
form of step-01; the span for step-01 carries the id 1560680610ff9cd2, derived from its
request id. Across the whole lab batch, every one of the three child spans pointed at
869f9ecc2e818034 and no span had it.
What this costs you: spans arrive in the right trace, with the right attributes and the right
dollars, and a backend’s waterfall renders them as siblings rather than as a tree. What it does
not cost you: the tree itself, which is intact on the ledger and rendered correctly by
tokentriage top --trace. This is filed; until it is fixed, read the shape from the ledger and
use the collector for correlation with your other services.
What can go wrong
Nothing arrives at all. Check dashboard.enabled, then check the endpoint path. The endpoint
is the full traces URL and must end in the collector’s traces path; a host without one gets a 404
that shows up as export failures rather than as a startup error.
Auth is rejected. headers_env is a variable name. Putting the header value directly in the
config field sends a literal environment-variable name as your token.
Timestamps look wrong. startTimeUnixNano and endTimeUnixNano are derived from the row’s
timestamp and its total latency in milliseconds, so a sub-millisecond call produces a span whose
start and end are the same instant. Against a local mock every span is zero-duration for that
reason. Span duration is not the place to read latency; the histograms are.
Whether TokenTriage ingests OTel context as well as exporting it
Not for identity. The exporter is one-way: it derives trace and span ids from the ledger’s own
ids and does not adopt an inbound traceparent as the trace, which is the same explicit-identity
rule that governs the ledger.
There is a separate OTLP ingestion adapter for outcome signals, which accepts evaluation results back into the system. It is gated by its own configuration and is a different subject; the calibration and evals overview is where that path is documented.