learn-ai-observability-with-phoebe / Builder session 2 of 10
Learn AI Observability with Phoebe · Builder track · Session 2 of 10

OTel GenAI conventions: the names, and the ones that changed under you

This is the reference session, the one you come back to. It is also the session most likely to contradict something you read online, because three things about the GenAI semantic conventions have moved recently enough that most blog posts, most Stack Overflow answers and a fair number of shipping instrumentations are describing a spec that no longer exists. We start with those three, then go through the exact span names, the exact attribute names with their requirement levels, and the second dialect you will meet in the wild.

🟢 Builder track AI engineers · platform engineers · technical PMs 45 min
0-3 · Welcome 3-16 · What moved, and the trap table 16-40 · Build-along: audit, then re-emit 40-45 · Q&A
Part 0

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.

Live - presented in session Self-study - read after class Official sources covered
★ What you walk out with today The three changes that invalidate most published guidance, a deprecated-to-current trap table you can grep your codebase against, the five span-name templates spelled exactly, the inference attribute set grouped by requirement level, the five attributes a head sampler can act on, and a working map of OpenInference as the other dialect.
Read this before you trust anything below Every span, metric, event and attribute in the GenAI semantic conventions carries status 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.
Part 1 · what moved

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.

1 · The repo moved new address The conventions left the main semconv docs for their own repository: open-telemetry/semantic-conventions-genai The opentelemetry.io gen-ai page is now a redirect notice. 2 · A name vanished zero hits in registry gen_ai.system -> gen_ai.provider.name (Required) It appears zero times in the current registry. If a page tells you to set gen_ai.system, that page was written against the 2024 or 2025 spec. 3 · Nothing is stable no tags, no releases Every span, metric, event and attribute carries status Development, and the new repo has no git tags and no releases. There is no version to pin, so there is no such thing as "the stable name". Check the repo per cohort. There is no stable GenAI convention. There is only today's, read from the repo.
🔍 Click to zoom - the three changes that date a piece of writing instantly
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 internetCurrent nameNote
gen_ai.systemgen_ai.provider.nameRequired. Zero occurrences of the old name in the current registry
gen_ai.usage.prompt_tokensgen_ai.usage.input_tokensStraight rename
gen_ai.usage.completion_tokensgen_ai.usage.output_tokensStraight rename
gen_ai.promptNo replacement. Use gen_ai.input.messagesObsoleted, 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.completionNo replacement. Use gen_ai.output.messagesObsoleted, same story
Five per-role events, gen_ai.user.message and friendsOne event: gen_ai.client.inference.operation.detailsDeprecated. 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_tokens to input_tokens is 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.

Part 2 · span names

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.

Span Name template Span kind Inference {gen_ai.operation.name} {gen_ai.request.model} CLIENT (or INTERNAL) Tool execute_tool {gen_ai.tool.name} INTERNAL Agent invoke_agent {gen_ai.agent.name} CLIENT or INTERNAL Workflow invoke_workflow {gen_ai.workflow.name} INTERNAL Plan plan {gen_ai.agent.name} INTERNAL One span, one logical operation - the retries live inside it chat claude-sonnet-4-5 2.9s ONE span try 1 429 try 2 429 try 3 200 OK Three HTTP attempts, one span. Retries are not siblings and not children. They vanish. If your trace shows one span per HTTP attempt, you are not following the spec.
🔍 Click to zoom - the five name templates, and where automatic retries go
The retry consequence, stated plainly One span covers the whole logical operation including automatic retries. That is a deliberate, defensible choice, and it also means the retry count is invisible unless you put it on the span as an attribute of your own. A span that took 2.9 seconds because the SDK retried twice looks identical to a span that took 2.9 seconds because the model was slow. b6 treats cascading retries as a named failure mode for exactly this reason.
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.

AttributeWell-known values
gen_ai.operation.name
18 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.name
16 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.

Part 3 · the attribute set

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

Required always, both gen_ai.operation.name gen_ai.provider.name Two names. Both enumerated, both low cardinality, both safe as metric labels. Conditionally Required when it applies error.type gen_ai.conversation.id gen_ai.output.type gen_ai.request.model gen_ai.request.stream gen_ai.request.seed gen_ai.request.choice.count gen_ai.request.top_k server.port gen_ai.prompt.name gen_ai.prompt.version error.type is the one Stable attribute in the whole set. Everything else is Development. Recommended set it if you can gen_ai.usage.input_tokens gen_ai.usage.output_tokens gen_ai.usage.cache_read.input_tokens gen_ai.usage.reasoning.output_tokens gen_ai.usage.cache_creation.input_tokens gen_ai.response.finish_reasons gen_ai.request.temperature gen_ai.response.model server.address cache_read and cache_creation input tokens are INCLUDED IN input_tokens - do not add Eleven more request and response parameters are Recommended - full table below. Opt-In sensitive, off gen_ai.input.messages gen_ai.output.messages gen_ai.system_instructions gen_ai.tool.definitions gen_ai.prompt.variable.<key> Off by default. Instrumentations SHOULD NOT capture these unless you opt in. See b10. The sampling-time set - SHOULD be present when the span is created gen_ai.operation.name gen_ai.provider.name gen_ai.request.model server.address server.port The low-cardinality head. This is the only set a head sampler can act on - see b7.
🔍 Click to zoom - the inference attribute set by requirement level, and the sampling-time head
LiveThe full inference-span table, as the registry groups it5 min▶
LevelAttributesWhat the level is telling you
Requiredgen_ai.operation.name
gen_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 Requirederror.type if the operation errored
gen_ai.conversation.id
gen_ai.output.type
gen_ai.request.model if available
gen_ai.request.stream if and only if streaming
gen_ai.request.seed
gen_ai.request.choice.count if not 1
gen_ai.request.top_k
gen_ai.prompt.name and gen_ai.prompt.version
server.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
Recommendedgen_ai.request.max_tokens, .temperature, .top_p, .frequency_penalty, .presence_penalty, .stop_sequences, .reasoning.level
gen_ai.response.finish_reasons (array), .id, .model, .time_to_first_chunk in seconds if streaming
gen_ai.usage.input_tokens, .output_tokens, .cache_read.input_tokens, .cache_creation.input_tokens, .reasoning.output_tokens
server.address
gen_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-Ingen_ai.input.messages
gen_ai.output.messages
gen_ai.system_instructions
gen_ai.tool.definitions
gen_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:

★ The inclusion rule

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▶
AttributeLevelNote
gen_ai.operation.nameRequiredValue execute_tool
gen_ai.tool.nameRequiredAlso the second half of the span name
error.typeConditionally RequiredThe Stable one again
gen_ai.agent.nameConditionally RequiredWhich agent called the tool
gen_ai.tool.call.idRecommendedJoins the call to its result. Unbounded, so never a metric label
gen_ai.tool.typeRecommendedfunction client-side, extension agent-side, datastore
gen_ai.tool.call.arguments and gen_ai.tool.call.resultOpt-InTreated 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.

Demo 1 of 2

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.

What the audit usually finds

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

Do the constants file before the migration Put every attribute key in one module as a named constant, then migrate. It costs twenty minutes and it turns the next rename from a codebase-wide grep into a one-file diff. Given a spec with no tags and no releases, assume there will be a next rename.
Demo 2 of 2

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.

★ The shape - creation-time set, then response, then error

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

The one line to argue about 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.
Part 4 · the other signal, briefly

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.

MetricInstrumentUnitBuckets
gen_ai.client.token.usageHistogram{token}Powers of four: 1, 4, 16, 64, 256, 1024, 4096, 16384, 65536, 262144, 1048576, 4194304, 16777216, 67108864
gen_ai.client.operation.durationHistograms0.01 up to 81.92, fourteen doubling buckets
gen_ai.client.operation.time_to_first_chunkHistogramsSame ladder as duration
gen_ai.client.operation.time_per_output_chunkHistogramsSame ladder as duration
gen_ai.server.request.durationHistograms0.01 up to 81.92
gen_ai.server.time_to_first_tokenHistograms0.001 up to 10.0
gen_ai.server.time_per_output_tokenHistograms0.01 up to 2.5
gen_ai.invoke_workflow.durationHistograms1, 5, 10, 30, 60, 120, 300, 600, 1800, 3600, 7200
gen_ai.invoke_agent.durationHistograms0.1 up to 409.6
gen_ai.invoke_agent.inference_callsHistogram{inference_call}1, 2, 4, 8, 16, 32, 64, 128
gen_ai.invoke_agent.tool_callsHistogram{tool_call}1, 2, 4, 8, 16, 32, 64, 128
gen_ai.execute_tool.durationHistograms0.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.

Bucket boundaries are a spec author telling you what they expect to happen Token counts bucketed in powers of four rather than powers of two, reaching 67 million. Workflow durations reaching two hours. Agent inference and tool call counts reaching 128. Nobody chooses a 128-call bucket for a well-behaved system. Those two counters are how a runaway loop becomes visible on a dashboard without anyone opening a trace, which is b3's second reading rule and b8's best alert.
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.

Part 5 · the other dialect

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 GenAIOpenInference
What marks the spangen_ai.operation.name plus gen_ai.provider.nameopeninference.span.kind, required on every span
Span kindsFive named span shapes: inference, tool, agent, workflow, planTen: LLM, EMBEDDING, CHAIN, RETRIEVER, RERANKER, TOOL, AGENT, GUARDRAIL, EVALUATOR, PROMPT
Token countsgen_ai.usage.input_tokens, gen_ai.usage.output_tokensllm.token_count.prompt, .completion, .total
CostNot in the spec at all. You derive itFirst-class llm.cost.*, including cache read and cache write
Graph topologyNot modelledgraph.node.id, .name, .parent_id
User identityNone. There is no gen_ai.user.iduser.id
Content by defaultOpt-in, and OFFON, and you hide with OPENINFERENCE_HIDE_* flags, all defaulting to False
StabilityDevelopment. No tags, no releasesVersioned and production-used
The single most consequential difference: the privacy default is inverted OTel GenAI says instrumentations SHOULD NOT capture message content, and gives you a switch to turn it on. OpenInference captures content by default and gives you a family of 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.

Homework

Try it yourself - this week ◐ 45-60 min total

Source material

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.

✓The move to open-telemetry/semantic-conventions-genai, and the redirect notice on the opentelemetry.io gen-ai pagePart 1 · the reason most published guidance is stale
✓The deprecated registry: gen_ai.system, the token renames, the obsoleted content attributes, the collapsed message eventsPart 1 · the trap table, greppable
✓GenAI span conventions: the five name templates, span kinds, and one span per logical operation including retriesPart 2 · with the enum values for both Required attributes
✓Inference and tool span attributes with requirement levels, the sampling-time set, and the cache token inclusion rulePart 3 · the reference table for the whole course
✓GenAI metric conventions: names, units, bucket boundaries, the Required gen_ai.token.type, and the excluded identifiersPart 4 · named here, alerted on in b8
✓OpenInference as the second dialect: ten span kinds, openinference.span.kind, llm.token_count.*, llm.cost.*, user.id, the inverted content defaultPart 5 · the choice a dependency makes for you
◐The agent, workflow and plan nesting rules, and which framework actually emits any of thisNamed here · the full shape and the framework landscape are b3
◐Content capture: the three usage patterns, the environment variable nuance, the upload hookLevel named here · the decision is b10
◐Head and tail sampling policies over the sampling-time setSet introduced here · b7 does the sampling
Check yourself

Three 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

Where the spec lives nowopen-telemetry/semantic-conventions-genai. The opentelemetry.io gen-ai page is a redirect notice. No tags, no releases, nothing to pin.
The name that is gonegen_ai.system is deprecated and appears zero times in the registry. Use gen_ai.provider.name. It is Required.
The two Required attributesgen_ai.operation.name and gen_ai.provider.name. Both enumerated, both low cardinality, both on every inference span.
Span name templates{operation} {request.model} · execute_tool {tool.name} · invoke_agent {agent.name} · invoke_workflow {workflow.name} · plan {agent.name}.
One span, including retriesA span covers the whole logical operation. Automatic retries are inside it and invisible unless you add an attribute yourself.
The sampling-time fivegen_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 tokens are includedcache_read.input_tokens and cache_creation.input_tokens are inside input_tokens. Split it, never sum it.
The inverted defaultOTel GenAI content is Opt-In and OFF. OpenInference is ON by default and you hide with OPENINFERENCE_HIDE_*. Your dependency picks your privacy posture.