Why a naming session earns 45 minutes
Session b1 said "use the standard keys" and deferred the keys to today. The reason it is worth a whole session is that conventional attribute names are the only part of your telemetry that ports. Duration and parenting come free from any tracer. The names are the interface between your code and every dashboard, every alert, every sampling policy and every vendor you might move to next year - and getting one of them wrong is silent. A misspelled attribute does not error. It just never appears in the query you write eighteen months later during an incident.
Development. The dedicated repo has no git tags and no releases, which means there is no version number for you to pin and no release notes to diff. Everything on this page was read from the repository on 13 August 2026. Treat it as correct on that date and as a habit rather than a fact: the skill this session actually teaches is going to the registry yourself.
Three things that invalidate most blog posts 7 min live
If you take nothing else from today, take these three. Each one is a specific, checkable claim, and each one is something you can find in a currently published tutorial written with complete confidence.
LiveThe deprecated-to-current trap table5 min▶
Grep your codebase for the left column. Everything you find is either dead or reporting into a query nobody maintains any more.
| Old name, still all over the internet | Current name | Note |
|---|---|---|
gen_ai.system | gen_ai.provider.name | Required. Zero occurrences of the old name in the current registry |
gen_ai.usage.prompt_tokens | gen_ai.usage.input_tokens | Straight rename |
gen_ai.usage.completion_tokens | gen_ai.usage.output_tokens | Straight rename |
gen_ai.prompt | No replacement. Use gen_ai.input.messages | Obsoleted, with the words "no replacement at this time". The structured messages attribute is the thing to reach for, but it is not a rename and the shapes differ |
gen_ai.completion | No replacement. Use gen_ai.output.messages | Obsoleted, same story |
Five per-role events, gen_ai.user.message and friends | One event: gen_ai.client.inference.operation.details | Deprecated. The five collapsed into one |
That last row is the fastest dating test there is. If a page shows you five gen_ai.*.message events, it is describing the 2024 or 2025 spec, and you should assume everything else on the page is the same vintage. One event now carries the operation detail, and content rides on it or on the span.
Note the asymmetry between rows two and four. The token renames are safe mechanical substitutions. The content attributes are not: gen_ai.prompt was obsoleted rather than renamed, and the registry says so in those words. gen_ai.input.messages is a structured attribute with a mandatory JSON schema, not a string, and it is Opt-In rather than Recommended. Swapping the name without changing the shape produces telemetry that validates against nothing.
Self-studyWhy a moving spec is still worth adopting3 min read▶
The reasonable objection to everything above is: if none of it is stable, why not invent my own names and be done with it? Three answers, in increasing order of how much they will matter to you.
- Renames are mechanical, inventions are not.
prompt_tokenstoinput_tokensis a find-and-replace plus a dashboard edit. Migrating off a bespoke schema means re-deriving what you meant, usually from memory, usually during a migration you did not plan. - The tooling reads these names. Grafana's AI dashboards, tail-sampling policies, and every LLM-aware backend key off
gen_ai.*. Bespoke names mean you write the dashboards too. - The requirement levels encode judgement you do not have to repeat. Someone already argued about whether temperature belongs on a span, whether tool arguments are sensitive, and which five attributes a sampler can afford to look at. Adopting the names adopts the arguments.
The honest position is: adopt the conventions, wrap the attribute keys in constants in one file, and expect to change that file once or twice a year. Do not scatter string literals through your codebase, because the whole point of a Development-status spec is that the strings will move.
Five templates, and the retry rule 6 min live
Span names are not free text and they are not function names. Each GenAI span type has a template, and the template exists because the name becomes a dashboard row, a group-by key and a sampling predicate. b1 said "name it for the operation". Here is what the spec means by that, exactly.
LiveThe two enum-valued Required attributes3 min▶
Both Required attributes are enumerations with well-known values, which is what makes them safe as metric dimensions and as sampler predicates. Use the well-known value when one fits; the spec permits others, but every value you invent is a value no dashboard knows about.
| Attribute | Well-known values |
|---|---|
gen_ai.operation.name18 well-known values | chat, embeddings, execute_tool, invoke_agent, invoke_workflow, plan, retrieval, text_completion, generate_content, fetch_response, plus the memory family |
gen_ai.provider.name16 well-known values | anthropic, openai, aws.bedrock, gcp.vertex_ai, azure.ai.openai, cohere, deepseek, groq, mistral_ai, x_ai, perplexity, and others |
Two things worth noticing. First, execute_tool, invoke_agent, invoke_workflow and plan appear both as span-name prefixes and as operation-name values, which is not redundant: the name is for humans reading a waterfall, the attribute is for machines grouping one. Set both.
Second, gen_ai.provider.name distinguishes the provider from the hosting platform, which is why aws.bedrock, gcp.vertex_ai and azure.ai.openai are values in their own right. If you call Claude through Bedrock, the platform is what your telemetry can actually observe, and b9 will show you why that distinction matters when one platform degrades and another does not.
Requirement levels, and the five a sampler can see 8 min live
This is the table you will screenshot. The grouping is the spec's own, and the grouping is the interesting part: requirement level is not a measure of usefulness, it is a statement about when an instrumentation is allowed to omit something. Read the Conditionally Required group as "required, when the condition holds" rather than as "optional".
LiveThe full inference-span table, as the registry groups it5 min▶
| Level | Attributes | What the level is telling you |
|---|---|---|
| Required | gen_ai.operation.namegen_ai.provider.name | A GenAI inference span without these two is not a GenAI inference span. They are also the two things every aggregate query groups by |
| Conditionally Required | error.type if the operation erroredgen_ai.conversation.idgen_ai.output.typegen_ai.request.model if availablegen_ai.request.stream if and only if streaminggen_ai.request.seedgen_ai.request.choice.count if not 1gen_ai.request.top_kgen_ai.prompt.name and gen_ai.prompt.versionserver.port if server.address is set | Each has a stated condition. error.type is the standout: it is the one Stable attribute in the whole set, which tells you the general error convention settled long before the GenAI one did. gen_ai.request.choice.count only appears when it is not 1, so absence means one choice rather than unknown |
| Recommended | gen_ai.request.max_tokens, .temperature, .top_p, .frequency_penalty, .presence_penalty, .stop_sequences, .reasoning.levelgen_ai.response.finish_reasons (array), .id, .model, .time_to_first_chunk in seconds if streaminggen_ai.usage.input_tokens, .output_tokens, .cache_read.input_tokens, .cache_creation.input_tokens, .reasoning.output_tokensserver.addressgen_ai.conversation.compacted | Recommended is where nearly all the diagnostic power lives, which is the mild joke of this table. The request parameters explain behaviour changes with no deploy. gen_ai.response.model next to gen_ai.request.model is how you catch a floating alias moving under you, which is b9. gen_ai.conversation.compacted is a positive indicator only: set it true or leave it off, never false |
| Opt-In | gen_ai.input.messagesgen_ai.output.messagesgen_ai.system_instructionsgen_ai.tool.definitionsgen_ai.prompt.variable.<key> | Opt-In means the instrumentation SHOULD NOT capture it by default but SHOULD give you a switch. This is a privacy posture expressed as a requirement level, and the whole of b10 unpacks it |
Two pieces of token guidance that are easy to miss and expensive to miss. Report the billed count, not your own tokeniser's estimate, which is the only number that reconciles with an invoice. And fetch_response operations SHOULD NOT report token usage at all, because the tokens were billed on the original request and reporting them again double-counts.
LiveThe cache token trio, and why b4 depends on it3 min▶
This is the single most consequential sentence in the attribute registry for anyone who has to explain a bill:
gen_ai.usage.cache_read.input_tokens is INCLUDED IN gen_ai.usage.input_tokens gen_ai.usage.cache_creation.input_tokens is INCLUDED IN gen_ai.usage.input_tokens input_tokens is the total. The other two are breakdowns of it, not additions to it.
So the arithmetic for cost is a weighted split of one number, not a sum of three. If you add all three you over-report input tokens on every cached request, and the over-report scales with how well your caching works - your cost dashboard gets worse precisely as your caching gets better. b4 does the full cost model with real multipliers; today just get the inclusion direction right in your head.
Worth naming the second, sharper trap now so it does not surprise you in b4: what a provider reports as input tokens and what the OTel attribute means are not automatically the same thing, and provider-side cache breakpoints can make the raw input_tokens field describe only part of the prompt. Log the breakdown, not just the total, and reconcile against a real invoice once before you trust any dashboard built on it.
Self-studyThe tool span, which is smaller and sharper than you expect3 min read▶
| Attribute | Level | Note |
|---|---|---|
gen_ai.operation.name | Required | Value execute_tool |
gen_ai.tool.name | Required | Also the second half of the span name |
error.type | Conditionally Required | The Stable one again |
gen_ai.agent.name | Conditionally Required | Which agent called the tool |
gen_ai.tool.call.id | Recommended | Joins the call to its result. Unbounded, so never a metric label |
gen_ai.tool.type | Recommended | function client-side, extension agent-side, datastore |
gen_ai.tool.call.arguments and gen_ai.tool.call.result | Opt-In | Treated as sensitive, and correctly so: tool arguments are very often where the account number is |
Six attributes and two switches. The important design point is that gen_ai.tool.name is Required while the arguments are Opt-In, which means the default tool span tells you which tool ran and nothing about what it was asked. That is enough to catch wrong-tool-selection and not enough to catch a malformed argument. b6 makes that trade explicit; b10 makes it a policy.
Audit the Beacon support chatbot against the registry ★ 10 min · everyone builds
Beacon's support chatbot has been instrumented for a year, by three different people, following whatever guide was top of the search results at the time. That is a normal codebase. Before adding anything, find out what it is actually emitting. This audit takes ten minutes and it usually finds two of the six trap-table rows.
Query your backend for the distinct attribute keys present on spans from the last thousand requests. Not the values, just the keys. Most platforms can do this; if yours cannot, dump one span as JSON and read the key list by hand. This list, not your code, is the truth about what you emit.
Grep the codebase for the left column of the trap table. Six strings: gen_ai.system, prompt_tokens, completion_tokens, gen_ai.prompt, gen_ai.completion, and gen_ai.user.message. Each hit is a place your telemetry is describing a spec that no longer exists.
Check the two Required attributes on every inference span. Not "we set the model", the two Required ones: gen_ai.operation.name and gen_ai.provider.name. A span missing either is not queryable as a GenAI span by any tool that follows the conventions, and the failure mode is that it silently drops out of every aggregate.
Check whether error.type ever appears. If it appears zero times across a thousand spans, either nothing has failed all week, or - overwhelmingly more likely - your failure path does not set it. Hold that thought until b6.
Write the results down as a three-column list: key you emit, current name, requirement level. That list is your migration ticket, and it is short enough to finish in an afternoon.
gen_ai.system = "anthropic" deprecated, appears zero times in the registry gen_ai.usage.prompt_tokens = 1841 renamed to gen_ai.usage.input_tokens gen_ai.prompt = "[{\"role\": ...}]" obsoleted, no replacement, and Opt-In content on by mistake llm.model = "claude-sonnet-4-5" bespoke. Not wrong, just invisible to every standard tool error.type absent. On 1,000 spans. Including the failing ones
Emit one correct inference span on the Analyst Bot ★ 12 min · everyone builds
The Analyst Bot turns a question into a warehouse query. Its model call is the span we are going to get exactly right, in the order the spec cares about: the sampling-time set first, at creation, then the response attributes on the way out, then the error path. The shape below is Python; the ordering argument is language-independent.
Set the sampling-time set at span creation, not after. Five attributes: gen_ai.operation.name, gen_ai.provider.name, gen_ai.request.model, server.address, server.port. The spec says these SHOULD be present when the span is created because a head sampler decides at creation and cannot see anything you add later. Attributes added after the decision are invisible to sampling by construction.
Name the span from the template: {gen_ai.operation.name} {gen_ai.request.model}, so chat claude-sonnet-4-5. Note it is the request model in the name. The response model goes on as an attribute, and b9 is entirely about the day those two stop matching.
On the way out, set the response attributes: gen_ai.response.id, gen_ai.response.model, gen_ai.response.finish_reasons as an array, and the usage numbers. Report the billed counts. Add the cache breakdown if the provider gives it to you, remembering it is a breakdown of input_tokens and not an addition to it.
On the failure path, set error.type. It is Conditionally Required, it is the one Stable attribute you will touch today, and it is the difference between an error rate you can chart and an error rate you cannot.
Leave the content attributes off. gen_ai.input.messages and gen_ai.output.messages are Opt-In, off is the spec's default, and the decision to turn them on is a privacy and retention decision rather than an instrumentation one. b10 makes that decision properly.
Now check your own work the way the registry would: for every attribute you set, say out loud which requirement level it sits at. Anything you cannot place is either bespoke or misremembered.
attrs = { "gen_ai.operation.name": "chat", # Required "gen_ai.provider.name": "anthropic", # Required "gen_ai.request.model": model, # sampling-time "server.address": "api.anthropic.com", # sampling-time "server.port": 443, # sampling-time } with tracer.start_as_current_span(f"chat {model}", attributes=attrs) as span: try: resp = client.messages.create(...) span.set_attribute("gen_ai.response.model", resp.model) span.set_attribute("gen_ai.usage.input_tokens", resp.usage.input_tokens) span.set_attribute("gen_ai.usage.output_tokens", resp.usage.output_tokens) span.set_attribute("gen_ai.response.finish_reasons", [resp.stop_reason]) except Exception as e: span.set_attribute("error.type", type(e).__qualname__) span.set_status(Status(StatusCode.ERROR)) raise
attributes=attrs passed into start_as_current_span rather than five set_attribute calls afterwards. It reads as a style preference and it is not one: a head sampler runs at creation, so the same five attributes set one line later are invisible to it. This is the only place in the session where being pedantic about ordering changes what your pipeline can do, and it is why b7 keeps coming back to this five-attribute set.
Metric names, and the powers-of-four buckets 4 min live
The conventions define metrics as well as spans, and you should know the names now so you recognise them later. b8 does the alerting work; today is names, units and one design detail worth reading closely.
| Metric | Instrument | Unit | Buckets |
|---|---|---|---|
gen_ai.client.token.usage | Histogram | {token} | Powers of four: 1, 4, 16, 64, 256, 1024, 4096, 16384, 65536, 262144, 1048576, 4194304, 16777216, 67108864 |
gen_ai.client.operation.duration | Histogram | s | 0.01 up to 81.92, fourteen doubling buckets |
gen_ai.client.operation.time_to_first_chunk | Histogram | s | Same ladder as duration |
gen_ai.client.operation.time_per_output_chunk | Histogram | s | Same ladder as duration |
gen_ai.server.request.duration | Histogram | s | 0.01 up to 81.92 |
gen_ai.server.time_to_first_token | Histogram | s | 0.001 up to 10.0 |
gen_ai.server.time_per_output_token | Histogram | s | 0.01 up to 2.5 |
gen_ai.invoke_workflow.duration | Histogram | s | 1, 5, 10, 30, 60, 120, 300, 600, 1800, 3600, 7200 |
gen_ai.invoke_agent.duration | Histogram | s | 0.1 up to 409.6 |
gen_ai.invoke_agent.inference_calls | Histogram | {inference_call} | 1, 2, 4, 8, 16, 32, 64, 128 |
gen_ai.invoke_agent.tool_calls | Histogram | {tool_call} | 1, 2, 4, 8, 16, 32, 64, 128 |
gen_ai.execute_tool.duration | Histogram | s | 0.01 up to 81.92 |
gen_ai.client.token.usage requires gen_ai.token.type, whose values are input and output. That is why one metric covers both directions rather than two metrics: token type is a dimension, not a name. It also means every query you write against token usage must filter or group by gen_ai.token.type, or you will silently be summing prompts and completions together.
Self-studyWhat every metric attribute set deliberately leaves out2 min read▶
Read the metric attribute lists and notice the absences: no conversation id, no response id, no user id, no tool call id. That is not an oversight, it is the cardinality guardrail written into the spec. Metric cost scales with the number of distinct time series, which is the Cartesian product of label values, so one unbounded label turns a cheap counter into an unbounded bill.
gen_ai.request.model is bounded at tens of values and is safe. gen_ai.conversation.id is unbounded and is not. The rule that follows: unbounded identifiers are fine on spans and never acceptable as metric dimensions. b7 works through the arithmetic and the collector configuration that enforces it.
There is one more signal worth knowing exists: the event gen_ai.evaluation.result, with gen_ai.evaluation.name, .score.value, .score.label and .explanation. Evaluation is becoming part of the telemetry spec rather than a separate discipline, which is a genuinely interesting development and the spine of b9.
OpenInference, and the inverted default 6 min live
You will meet a second vocabulary, and you will meet it sooner than you expect, because it is what several of the most widely used instrumentation packages emit. It is not a competitor standard in the sense of a different wire format: every OpenInference trace is a valid OTLP trace. It is a different set of names on the same envelope.
| OTel GenAI | OpenInference | |
|---|---|---|
| What marks the span | gen_ai.operation.name plus gen_ai.provider.name | openinference.span.kind, required on every span |
| Span kinds | Five named span shapes: inference, tool, agent, workflow, plan | Ten: LLM, EMBEDDING, CHAIN, RETRIEVER, RERANKER, TOOL, AGENT, GUARDRAIL, EVALUATOR, PROMPT |
| Token counts | gen_ai.usage.input_tokens, gen_ai.usage.output_tokens | llm.token_count.prompt, .completion, .total |
| Cost | Not in the spec at all. You derive it | First-class llm.cost.*, including cache read and cache write |
| Graph topology | Not modelled | graph.node.id, .name, .parent_id |
| User identity | None. There is no gen_ai.user.id | user.id |
| Content by default | Opt-in, and OFF | ON, and you hide with OPENINFERENCE_HIDE_* flags, all defaulting to False |
| Stability | Development. No tags, no releases | Versioned and production-used |
OPENINFERENCE_HIDE_* flags to turn it off, every one of which defaults to False. So the same application, instrumented with two packages that both emit valid OTLP, has opposite data-protection postures out of the box. If you install an OpenInference package on Friday and nobody changes a flag, you are storing prompts on Monday. That is a policy decision made by a dependency, and b10 is where we deal with it properly.
Self-studyWhich dialect to choose, and the honest answer3 min read▶
The honest answer is that you often do not choose, because the dialect comes with the instrumentation package, and the package comes with whichever framework you are using. What you choose is where you normalise.
Notice that OpenInference has things the GenAI conventions simply lack: user.id, first-class cost, graph topology, and two span kinds - EVALUATOR and PROMPT - that describe parts of an LLM system the GenAI spec does not model as spans at all. Notice also that it is versioned, which for an operational dependency is worth real money.
Against that, gen_ai.* is what the generic tooling reads, what the tail-sampling examples are written against, and what a general-purpose APM will grow support for. And interop happens at the backend rather than at the producer: everyone emits OTLP and the platform normalises the names. Langfuse, for instance, documents reading OpenInference keys, gen_ai.* keys and MLflow keys.
So the practical rule: pick the dialect your instrumentation gives you, be consistent within one service, and put the normalisation in one place you control. What you must not do is emit both dialects from the same service and let each dashboard pick a favourite. a6 turns this into a buy-or-build decision.
Try it yourself - this week ◐ 45-60 min total
- Open
open-telemetry/semantic-conventions-genaiyourself and confirm three things from this page against the source: thatgen_ai.systemis absent from the registry, thaterror.typeis marked Stable while its neighbours are Development, and that there are still no tags or releases. If any of the three has changed since 13 August 2026, you have just learned the actual lesson of this session. - Finish the Demo 1 audit on a real service and turn the output into a migration ticket with the three columns. Count the bespoke keys separately from the deprecated ones: deprecated keys are a rename, bespoke keys are a decision someone has to make.
- Move every attribute key in one service into a constants module. Then measure how long the next rename would take. That number is your real coupling to a Development-status spec.
- Write out the sampling-time set from memory, then check it. Five attributes. If you cannot reproduce them, b7 will not land, because every head-sampling policy you write has to live inside that list.
- Find one published tutorial on instrumenting an LLM app and date it using only the trap table. Look for
gen_ai.systemand for five per-role message events. It is a two-minute exercise and it permanently changes how you read this genre of writing.
Official sources covered
Every name on this page was read from a primary source on 13 August 2026 and catalogued in this course's source map. Names are quoted, not paraphrased, because a paraphrased attribute name is a wrong attribute name.
open-telemetry/semantic-conventions-genai, and the redirect notice on the opentelemetry.io gen-ai pagePart 1 · the reason most published guidance is stalegen_ai.system, the token renames, the obsoleted content attributes, the collapsed message eventsPart 1 · the trap table, greppablegen_ai.token.type, and the excluded identifiersPart 4 · named here, alerted on in b8openinference.span.kind, llm.token_count.*, llm.cost.*, user.id, the inverted content defaultPart 5 · the choice a dependency makes for youThree questions before you go 🎯 ◐ 90 seconds
1 · A well-written tutorial shows you five events named gen_ai.user.message, gen_ai.assistant.message and so on, and tells you to set gen_ai.system. What do you conclude?
Those two markers together date a page precisely, which is why they are worth memorising. Neither name is a valid alternative: one is deprecated in favour of a Required attribute, and the five per-role events were deprecated in favour of a single operation-details event. Option C is a real confusion but a different one, and OpenInference would be marked by openinference.span.kind rather than by either of these.
2 · A request reports gen_ai.usage.input_tokens of 12,400 and gen_ai.usage.cache_read.input_tokens of 11,900. How many input tokens did the request use?
The inclusion direction is the whole answer, and getting it backwards produces a cost dashboard that degrades as your caching improves, which is a genuinely confusing way to be wrong. 11,900 of the 12,400 came from cache and are billed at a different rate, which is why you log the breakdown rather than only the total. Option B is the second trap: cached tokens are cheaper, not free, and they are still input tokens. b4 does the money.
3 · You set gen_ai.request.model immediately after starting the span rather than passing it in at creation. What have you broken?
For a trace you keep, the attribute is on the span either way and nothing looks wrong. The damage is to a decision that already happened: a head sampler ran at creation with only what it could see. So your sampling policy silently cannot key off the model, the provider, the operation or the server address, and the symptom is a policy that appears to do nothing. Five attributes, set at creation: gen_ai.operation.name, gen_ai.provider.name, gen_ai.request.model, server.address, server.port.
Builder session 2 cheat sheet · pin this
open-telemetry/semantic-conventions-genai. The opentelemetry.io gen-ai page is a redirect notice. No tags, no releases, nothing to pin.gen_ai.system is deprecated and appears zero times in the registry. Use gen_ai.provider.name. It is Required.gen_ai.operation.name and gen_ai.provider.name. Both enumerated, both low cardinality, both on every inference span.{operation} {request.model} · execute_tool {tool.name} · invoke_agent {agent.name} · invoke_workflow {workflow.name} · plan {agent.name}.gen_ai.operation.name, gen_ai.provider.name, gen_ai.request.model, server.address, server.port - set at creation or a head sampler cannot see them.cache_read.input_tokens and cache_creation.input_tokens are inside input_tokens. Split it, never sum it.OPENINFERENCE_HIDE_*. Your dependency picks your privacy posture.