The attribute mapping
Every attribute on an exported span, which ones follow the OpenTelemetry GenAI convention, and where TokenTriage extends it and why.
On this page
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
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.
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 covers.
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.