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

Source: https://tokentriage.com/docs/build/observability-otlp

---
import Callout from '../../../components/Callout.astro';
import Details from '../../../components/Details.astro';
import NextSteps from '../../../components/NextSteps.astro';

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

```yaml
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:

```console
$ 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:

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

<Callout type="warning">
This coupling is not documented in the config file and is not something you would infer from the
key names. If your collector is quiet, check `dashboard.enabled` first. The quickest check is
`/metrics`: `tokentriage_otlp_dropped_total` appears in the scrape when the exporter exists at
all, so its absence means the exporter was not built.
</Callout>

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

```json
{
  "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](/docs/build/observability-otel-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](/docs/build/observability-headers) 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.

<Details summary="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](/docs/calibration/overview) is where that path is documented.

</Details>

<NextSteps items={[
  {title: "The attribute mapping", href: "/docs/build/observability-otel-mapping", description: "Every attribute on a span, where it matches the GenAI convention, and where it extends it."},
  {title: "Quality on the wire", href: "/docs/build/observability-quality-events", description: "The evaluation events the exporter emits beside cost spans, and the rule that keeps an unprovable score off the wire."},
  {title: "What a drop means", href: "/docs/build/observability-drops", description: "The export queue's drop counter, and the three other things that can be thrown away."},
  {title: "Put identity on the wire", href: "/docs/build/observability-headers", description: "Untraced rows are not exported. This is how a row gets a trace id in the first place."},
]} />
