OTel GenAI Semconv in a Nutshell
Cheat sheet for OpenTelemetry's GenAI conventions: the span names, the attributes that matter, the metrics that got renamed, and how MCP trace context travels.
What it does
The GenAI semantic conventions are a naming scheme for telemetry from model calls, agents and tools: what a span is called, which attributes it carries, which metrics exist. They do not change what you instrument — they make the result comparable across providers, SDKs and dashboards.
Two things to know before depending on them. Every signal is still status Development, so attribute names move between releases. And the conventions now live in their own repository, open-telemetry/semantic-conventions-genai — the pages under opentelemetry.io/docs/specs/semconv/gen-ai/ are stubs that redirect there.
Why it exists
- One set of field names for sixteen providers.
gen_ai.provider.name: "aws.bedrock"and"openai"sit in the same column, so a cost dashboard does not fork per vendor. - A trace that matches the way agents actually run.
invoke_agent→chat→execute_toolnests naturally, so “why was this turn slow” has one obvious query. - Token accounting that survives caching. Input, output, cache-read, cache-write and reasoning tokens are separate attributes, because they have separate prices.
Cheat sheet: the spans
gen_ai.operation.name | Span name format | Kind | Where it comes from |
|---|---|---|---|
invoke_agent | invoke_agent {gen_ai.agent.name} | CLIENT | Calling a remote agent service |
invoke_agent | invoke_agent {gen_ai.agent.name} | INTERNAL | In-process agent loop |
create_agent | create_agent {gen_ai.agent.name} | CLIENT | Creating a hosted agent resource |
invoke_workflow | framework-specific | INTERNAL | Multi-agent or graph orchestration |
plan | plan | INTERNAL | Planning / task decomposition |
execute_tool | execute_tool {gen_ai.tool.name} | CLIENT or INTERNAL | One tool invocation |
chat | chat {gen_ai.request.model} | CLIENT | A model call |
generate_content | generate_content {gen_ai.request.model} | CLIENT | Gemini-style multimodal call |
embeddings | embeddings {gen_ai.request.model} | CLIENT | Vector generation |
retrieval | retrieval {gen_ai.data_source.id} | CLIENT | RAG fetch |
fetch_response | fetch_response | CLIENT | Re-read a stored response (no tokens) |
Required on every one of them: gen_ai.operation.name and gen_ai.provider.name. If a provider is proxied, record what the instrumentation believes — the spec says the attribute may differ from the actual upstream when a gateway relays calls.
Two naming rules that matter operationally: the model name goes in the span name, the session never does. chat gpt-4.1-mini is low cardinality; chat 2026-10-24T18:31:07Z destroys every aggregation you wanted.
Cheat sheet: the attributes
| Attribute | Level | Notes |
|---|---|---|
gen_ai.operation.name | Required | Drives the span name |
gen_ai.provider.name | Required | openai, anthropic, aws.bedrock, gcp.vertex_ai, deepseek, … |
gen_ai.request.model | Cond. required | Exact vendor model string |
gen_ai.response.finish_reasons | Recommended | Array, one per choice; use error for a truncated position |
gen_ai.usage.input_tokens | Recommended | Includes cached tokens |
gen_ai.usage.output_tokens | Recommended | Billed count, not “consumed”, where providers differ |
gen_ai.usage.cache_read.input_tokens | Recommended | Subset of input; the discount line |
gen_ai.usage.cache_write.input_tokens | Recommended | Subset of input; the premium line |
gen_ai.usage.reasoning.output_tokens | Recommended | Thinking tokens |
gen_ai.conversation.id | Cond. required | Only if the framework has a real one |
gen_ai.agent.name / .id / .version | Cond. required | id is the hosted resource ID — not an in-memory object id |
gen_ai.input.messages | Opt-in | Prompt content. Sensitive |
gen_ai.output.messages | Opt-in | Completion content. Sensitive |
gen_ai.system_instructions | Opt-in | System prompt. Sensitive |
gen_ai.tool.definitions | Opt-in | Can be large; not on by default |
The token rule that catches everyone: input_tokens is the total, and the cache counters are subsets of it, not additions. A request with 1,200 input tokens of which 900 were cached is input_tokens=1200, cache_read.input_tokens=900 — not 2,100. Summing them double-bills you.
Also note gen_ai.conversation.id must not be invented. The spec is explicit: a new UUID, the trace id, or a hash of the request are not acceptable fallbacks. If the framework has no conversation identifier, leave the attribute off.
The metrics were renamed — your dashboards are probably wrong
This is the single most disruptive change in the current draft. The old gen_ai.client.token.usage histogram is gone from the docs, replaced by one metric per token kind:
| Metric | Type | Unit |
|---|---|---|
gen_ai.client.inference.usage.input_tokens | Histogram | {token} |
gen_ai.client.inference.usage.output_tokens | Histogram | {token} |
gen_ai.client.inference.usage.cache_read.input_tokens | Histogram | {token} |
gen_ai.client.inference.usage.cache_write.input_tokens | Histogram | {token} |
gen_ai.client.inference.usage.reasoning.output_tokens | Histogram | {token} |
gen_ai.client.inference.operation.input_tokens | Histogram | {token} |
If you have a Grafana panel on gen_ai.client.token.usage, it has been flat-lining. The operation.* variants exist for calls whose usage you only learn after the fact — like fetch_response — where per-request attribution is impossible.
MCP gets its own duration metrics with explicit bucket boundaries: mcp.client.operation.duration and mcp.server.operation.duration on [0.01, 0.02, 0.05, 0.1, 0.2, 0.5, 1, 2, 5, 10, 30, 60, 120, 300] seconds. If you override buckets, override them to that list or cross-server comparisons stop working.
MCP tracing, and the _meta trick
MCP spans are named {mcp.method.name} {target} — tools/call read_file, tools/list — with mcp.method.name required. When the client is already tracing the tool execution as a GenAI execute_tool span, the MCP instrumentation should not create a second span; it should add MCP attributes to the existing one. Duplicate spans per tool call are the most common tracing mistake in agent stacks.
Context propagation is the interesting part. MCP is transport-independent, and one HTTP request can carry many JSON-RPC messages, so HTTP trace headers are not enough. The convention is to inject W3C context into the JSON-RPC params._meta bag:
{
"jsonrpc": "2.0",
"method": "tools/call",
"params": {
"name": "read_file",
"_meta": {
"traceparent": "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01",
"tracestate": "rojo=00f067aa0ba902b7"
}
},
"id": 1
}
Two subtleties: the keys are written unprefixed even though MCP normally DNS-prefixes _meta keys (that is SEP-414), and the receiver should treat _meta as the remote parent while linking the ambient transport context rather than parenting on it. Retries and multiplexing mean one HTTP request may serve several MCP requests, so the two contexts are genuinely independent.
Content is a decision, not a default
Instrumentations must not capture prompts or completions by default. The spec lays out three postures, and picking one deliberately is the difference between an observability bill you can explain and one you cannot:
| Posture | Records | Use for |
|---|---|---|
| Default | Nothing — only counts, latency, model, finish reason | Production, unless you have a reason |
| Attributes | gen_ai.input.messages, gen_ai.output.messages, gen_ai.system_instructions on the span | Pre-prod, or where telemetry storage meets your privacy bar |
| External reference | A pointer; content uploaded via a hook to storage with its own access control | Production with content-level debugging needs |
The hook runs regardless of the sampling decision, so sampled-out traces can still have their content archived — which is how you debug a 0.1% sample without paying to store everything.
Snippets
A correctly-shaped trace
from opentelemetry import trace
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import ConsoleSpanExporter, SimpleSpanProcessor
provider = TracerProvider()
provider.add_span_processor(SimpleSpanProcessor(ConsoleSpanExporter()))
trace.set_tracer_provider(provider)
tracer = trace.get_tracer("demo")
with tracer.start_as_current_span("invoke_agent support-bot") as agent:
agent.set_attribute("gen_ai.operation.name", "invoke_agent")
agent.set_attribute("gen_ai.provider.name", "openai")
agent.set_attribute("gen_ai.agent.name", "support-bot")
agent.set_attribute("gen_ai.conversation.id", "conv_123")
with tracer.start_as_current_span("chat gpt-4.1-mini") as call:
call.set_attribute("gen_ai.operation.name", "chat")
call.set_attribute("gen_ai.provider.name", "openai")
call.set_attribute("gen_ai.request.model", "gpt-4.1-mini")
call.set_attribute("gen_ai.usage.input_tokens", 1200)
call.set_attribute("gen_ai.usage.cache_read.input_tokens", 900)
call.set_attribute("gen_ai.usage.output_tokens", 180)
call.set_attribute("gen_ai.response.finish_reasons", ["stop"])
With opentelemetry-sdk 1.45.1 and the console exporter, this prints exactly that nesting — parent_id of the chat span is the span_id of invoke_agent, and both names match the required formats. No instrumentation library, no collector: the conventions are a naming discipline first.
Injecting context into an MCP request
from opentelemetry.propagate import inject
params = {"name": "read_file", "arguments": {"path": "/repo/README.md"}, "_meta": {}}
inject(params["_meta"]) # adds traceparent / tracestate / baggage
The default propagator is W3C trace context plus baggage, which is exactly the key set the convention expects.
What a compliant span looks like on the wire
{
"name": "execute_tool read_file",
"kind": "SPAN_KIND_CLIENT",
"attributes": [
{ "key": "gen_ai.operation.name", "value": { "stringValue": "execute_tool" } },
{ "key": "gen_ai.provider.name", "value": { "stringValue": "openai" } },
{ "key": "gen_ai.tool.name", "value": { "stringValue": "read_file" } },
{ "key": "gen_ai.tool.call.id", "value": { "stringValue": "call_001" } }
]
}
Typical use cases
| Want | Instrument |
|---|---|
| “Which model is expensive?” | chat spans grouped by gen_ai.request.model, sum output_tokens |
| “Why is this turn slow?” | invoke_agent span duration split across child chat / execute_tool |
| “Does caching help?” | cache_read.input_tokens ÷ input_tokens per model |
| “Which tool fails?” | execute_tool {tool} spans with error.type |
| “Is the MCP server the bottleneck?” | mcp.server.operation.duration vs the client span |
| “Was this answer grounded?” | retrieval spans — but only with the opt-in document attributes |
Gotchas
- It is all
Development. Pin your semconv version next to your SDK version, or an upgrade will silently rename your metrics again. gen_ai.systemno longer exists. Current conventions requiregen_ai.provider.name; anything still emitting the old key will not join.- Span names must stay low cardinality. Model or tool name, never session, user or timestamp.
- Durations are seconds (
sin UCUM), not milliseconds. The MCP buckets above are seconds. input_tokensincludes cached tokens. Cache counters are subsets. Do not sum the family.fetch_responsehas no token usage. The spec says not to report it, because no inference happened.- MCP
mcp.session.idwill be empty against new servers. The2026-07-28MCP revision removed protocol sessions entirely, so the attribute has nothing to carry — see the MCP note. - Content attributes are PII. One
gen_ai.input.messagesattribute can hold an entire document, and it lands in your tracing backend, not your database.
Where it sits next to the other notes
The MCP note describes the wire this instrument measures, and the MCP auth note explains why a 401 shows up as a span error with error.type set to the JSON-RPC code. Sandboxing covers the other half of the same question: telemetry tells you what the agent did, the sandbox decides what it could have done.