# The attribute mapping

Every attribute on an exported span, which ones follow the OpenTelemetry GenAI convention, and where TokenTriage extends it and why.

Source: https://tokentriage.com/docs/build/observability-otel-mapping

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

One ledger row becomes one span. Its attributes come from two namespaces: `gen_ai.*`, which
follows the OpenTelemetry GenAI semantic conventions, and `tokentriage.*`, which carries what the
conventions do not yet cover.

Both are emitted on every span. A consumer that understands only the convention gets the model,
the provider, the token counts and the dollar; a consumer that understands the extension
additionally gets the provenance of that dollar, the counterfactual prices, and the identity the
row was attributed under.

## The span envelope

| Field | Value |
|---|---|
| `traceId` | the first 16 bytes of `SHA-256(trace.trace_id)`, lowercase hex |
| `spanId` | the first 8 bytes of `SHA-256(req_id)` |
| `parentSpanId` | the first 8 bytes of `SHA-256(trace.parent_id)`, absent when there is no parent |
| `name` | the served model, after any rewrite; `gen_ai.request` when the model is unknown |
| `kind` | `3`, `SPAN_KIND_CLIENT` |
| `startTimeUnixNano` | the row's timestamp |
| `endTimeUnixNano` | the start plus the row's total latency in milliseconds |
| resource | one attribute, `service.name: tokentriage` |
| scope | `github.com/tokentriage/platform/internal/otlpexport`, version `1` |

Ids are derived from the ledger's own ids rather than adopted from an inbound `traceparent`, so
the same row always encodes to the same span. A row with no trace id yields no span at all, which
is why untraced traffic does not reach a collector.

`spanId` and `parentSpanId` are derived from different id spaces in this build, so a child's
parent reference does not resolve. [The export
page](/docs/build/observability-otlp#a-limit-worth-knowing-before-you-build-on-the-trace-view)
carries the captured evidence and what it costs.

## `gen_ai.*` — the convention attributes

| Attribute | From | Notes |
|---|---|---|
| `gen_ai.request.model` | `request.model` | what the client asked for |
| `gen_ai.response.model` | the served model | after any rewrite |
| `gen_ai.provider.name` | `http.provider` | falls back to a name derived from the wire dialect |
| `gen_ai.usage.input_tokens` | `usage.input_tokens` | omitted when zero |
| `gen_ai.usage.output_tokens` | `usage.output_tokens` | omitted when zero |
| `gen_ai.usage.cache_read.input_tokens` | `usage.cache_read_tokens` | the provider's prompt-cache discount |
| `gen_ai.usage.cache_creation.input_tokens` | `usage.cache_write_tokens` | tokens written to the provider's cache |
| `gen_ai.usage.reasoning_tokens` | `usage.reasoning_tokens` | see the divergence noted below |
| `gen_ai.cost.amount` | `cost.usd` | proposed, not yet a convention |
| `gen_ai.cost.currency` | fixed `USD` | present when `amount` is |
| `gen_ai.cost.pricing_source` | `cost.pricing_snapshot` | proposed |
| `gen_ai.cost.estimated` | `cost.estimated` | proposed; emitted only when true |

The first seven follow the registry's current spelling, including `gen_ai.provider.name`, which
replaced the older `gen_ai.system`.

One diverges. The registry names the reasoning-token attribute
`gen_ai.usage.reasoning.output_tokens`, and TokenTriage emits `gen_ai.usage.reasoning_tokens`. A
consumer looking for the convention spelling will not find it, and a dashboard that sums reasoning
tokens across producers will miss these. This is filed; until it is fixed, map the extension
spelling in your collector if you need the aggregate.

## The rule that the cost attributes exist for

`gen_ai.cost.*` is absent as a whole on a row with no price. There is no
`gen_ai.cost.amount: 0`.

That distinction is the reason the attribute group was proposed rather than invented locally. A
genuinely free call and a call whose price is unknown are different facts, and a consumer that
sums the attribute has to be able to tell them apart. Emitting zero for an unknown price silently
understates spend for every consumer downstream, and the understatement is invisible because the
sum still looks like a sum.

So a priced row carries the whole group, and an unpriced row carries none of it plus
`tokentriage.cost.unpriced: true` in the extension namespace. The count of rows that carried no
amount is recoverable; a fabricated zero would not have been.

<Callout type="key">
The absence of `gen_ai.cost.amount` means the cost is unknown, and it is the only thing that means
that. If you build an aggregate over the attribute, count the spans that lacked it and report that
count beside your total.
</Callout>

## `tokentriage.*` — the extension attributes

| Attribute | Carries | Present when |
|---|---|---|
| `tokentriage.cost.usd` | the dollar | the row is priced |
| `tokentriage.cost.unpriced` | `true` | the row has no price |
| `tokentriage.cost.pricing_snapshot` | which price table produced the figure | a snapshot is known, priced or not |
| `tokentriage.cost.pricing_key` | the key the price was looked up under | a key was resolved |
| `tokentriage.cost.estimated` | `true` | the figure rests on estimated token counts |
| `tokentriage.cost.would_usd.<tier>` | what this call would have cost on each tier | counterfactual prices were computed |
| `tokentriage.usage.source` | where the token counts came from | always |
| `tokentriage.decision.mode` | `ledger`, `shadow` or `route` | always |
| `tokentriage.decision.tier` | the chosen tier | a tier was chosen |
| `tokentriage.decision.rule` | the policy rule that fired | a rule fired |
| `tokentriage.trace.agent` | the `x-tt-agent` value | the header was set |
| `tokentriage.trace.step_class` | the step class in force | always |
| `tokentriage.dim.<name>` | one attribute per business dimension | dimensions are on and set |

Four of these have no convention equivalent to converge on, and they are the reason the extension
namespace stays even if the cost proposal is accepted. `pricing_snapshot` and `pricing_key` are
provenance: they let somebody re-derive the figure months later against the table that produced
it. `would_usd.<tier>` is the counterfactual that makes shadow mode readable, and no convention
models a price that was not paid. And `dim.<name>` is your own vocabulary rather than a standard
one.

Attributes with a variable suffix are emitted in sorted order, so two identical rows encode to
byte-identical spans.

## Where the convention stands

The GenAI conventions moved out of the main OpenTelemetry semantic-conventions repository into a
dedicated one in June 2026, and no GenAI span, event, metric or attribute was marked stable as of
July 2026. There is no versioned release to pin a schema URL against yet.

That is worth knowing before you build a pipeline on these names: the convention half of this
table can change under you on the convention's own schedule, and the extension half changes on
TokenTriage's. Dual emission is the hedge. Both namespaces carry the same dollar today, so a
collector rule can prefer either and fall back to the other.

TokenTriage's maintainers filed two proposals against the conventions, and both address gaps this
mapping had to work around: `gen_ai.cost.*` for monetary cost, which the registry has no attribute
of any kind for, and `gen_ai.evaluation.*` extensions for how a quality score was obtained, which
[quality on the wire](/docs/build/observability-quality-events) covers.

<Details summary="What TokenTriage will do if the cost proposal is accepted">

Emit both vocabularies through a deprecation window, then converge on the standard keys for the
fields they overlap and keep the extension keys for what the standard does not cover.

That is already the shape of what ships: both namespaces are on the wire now, carrying the
identical dollar, so the migration is a consumer-side change rather than a producer-side cutover.
The rule about absence rather than zero carries across unchanged in either vocabulary.

</Details>

<NextSteps items={[
  {title: "Export spans to your collector", href: "/docs/build/observability-otlp", description: "The configuration, a captured payload, and the two categories of row that never arrive."},
  {title: "Quality on the wire", href: "/docs/build/observability-quality-events", description: "The evaluation events emitted beside these spans, and why an unprovable score arrives with no number."},
  {title: "What a cost figure means", href: "/docs/build/cost-figures", description: "The unpriced rule at its source: what licenses a dollar, and what is rendered when nothing does."},
  {title: "Decision ledger fields", href: "/docs/reference/ledger-fields", description: "The row every attribute here is read from, block by block."},
]} />
