
Table of Contents
By Khimananda Oli | Last reviewed: October 2026
OpenTelemetry's GenAI semantic conventions standardise how LLM calls appear in traces and metrics, under the gen_ai.* attribute namespace. A chat completion becomes a span named chat {gen_ai.request.model} carrying gen_ai.provider.name, gen_ai.operation.name and token counts — the same shape whether the call went to OpenAI, Anthropic or a self-hosted model. Prompt and completion text is deliberately opt-in, not captured by default.
What problem do GenAI semantic conventions actually solve?
Every LLM provider's SDK logs differently. One call it usage.prompt_tokens, another usageMetadata.promptTokenCount, a third buries the model name three levels into a response object. If you instrument three providers by hand you get three incompatible sets of fields, and a dashboard that has to special-case each one.
The GenAI semantic conventions fix this the way OpenTelemetry fixes everything else: by naming the fields once, under a reserved namespace, so a span from OpenAI, Anthropic, Bedrock or your own vLLM deployment carries the same attribute names with the same meaning. gen_ai.usage.input_tokens means the same thing regardless of who generated the span, so one query, one dashboard and one alert work across every provider you use. If you have not instrumented an application with OpenTelemetry before, instrument an app with OpenTelemetry covers the general mechanics this builds on; this article is specifically about the GenAI attributes layered on top.
One thing to know before you start: as of late 2026 this specification lives in its own repository, separate from the main OpenTelemetry semantic conventions, and is still marked Development stability — meaning attribute names can still change between releases. That already happened once, covered next, and it is the single most common mistake in anything written about this topic before the change.
Why does gen_ai.system keep showing up in outdated examples?
Because it used to be correct. The original attribute for naming the LLM provider was gen_ai.system. It is now deprecated, officially replaced by gen_ai.provider.name. Plenty of blog posts, SDK examples and even some dashboards written before the change still use the old name — and because deprecated attributes do not disappear from running systems, you can easily end up with half your spans tagging the provider one way and half the other.
| Attribute | Status | Use instead |
|---|---|---|
gen_ai.system | Deprecated | gen_ai.provider.name |
gen_ai.provider.name | Current (Development) | — |
gen_ai.provider.name takes a fixed set of values for well-known providers — openai, anthropic, aws.bedrock, azure.ai.openai, azure.ai.inference, cohere, deepseek, gcp.gemini, gcp.gen_ai, gcp.vertex_ai, groq, ibm.watsonx.ai, mistral_ai, perplexity, x_ai — with a custom string permitted for anything self-hosted or not yet listed. If you are starting fresh today, use gen_ai.provider.name everywhere and do not introduce gen_ai.system into new code, even though some instrumentation libraries still emit both for backward compatibility during the transition.
What does a real inference span look like?
Span names follow a fixed pattern per operation. For an inference call it is {gen_ai.operation.name} {gen_ai.request.model}; for a retrieval step it is {gen_ai.operation.name} {gen_ai.data_source.id}. gen_ai.operation.name itself takes one of a defined set of values — chat, text_completion, embeddings, generate_content, create_agent, invoke_agent, invoke_workflow, execute_tool, retrieval — so a single query can separate "how long do our chat calls take" from "how long does tool execution take" without string-matching on free text.
Two attributes are Required on every GenAI span: gen_ai.operation.name and gen_ai.provider.name. A handful more are Conditionally Required — error.type when the call failed, gen_ai.request.model when it is known, server.address/server.port when the client talks to a specific endpoint. The rest — gen_ai.response.model, gen_ai.usage.input_tokens, gen_ai.usage.output_tokens — are Recommended, meaning a span without them still conforms to the spec but is less useful. On error, error.type should match the provider's own error code, the exception's class name, or another low-cardinality identifier — not a raw, free-text error message, which would blow up cardinality in your backend.
A raw OTLP span, trimmed to the attributes that matter:
{
"name": "chat gpt-5-mini-2026-08-01",
"kind": "CLIENT",
"attributes": {
"gen_ai.operation.name": "chat",
"gen_ai.provider.name": "openai",
"gen_ai.request.model": "gpt-5-mini",
"gen_ai.response.model": "gpt-5-mini-2026-08-01",
"gen_ai.request.temperature": 0.2,
"gen_ai.request.max_tokens": 1024,
"gen_ai.usage.input_tokens": 842,
"gen_ai.usage.output_tokens": 311,
"gen_ai.response.finish_reasons": ["stop"],
"server.address": "api.openai.com",
"server.port": 443
},
"status": {"code": "OK"}
} Note what is absent: the actual prompt text and the model's reply. That omission is deliberate, not a bug in this example.
Why is prompt content opt-in, and how do you turn it on?
The specification is explicit that GenAI instrumentations SHOULD NOT capture prompt and completion content by default, and SHOULD provide an option to opt in. Prompts routinely contain customer names, account numbers, proprietary business logic and sometimes API keys pasted by mistake — sending all of that into your trace backend by default would turn observability into a data-leak vector. This is the same concern covered generally in protecting PII and secrets in LLM apps; the GenAI conventions bake the same caution directly into the span model.
When content capture is enabled, prompts and completions are recorded as span events rather than span attributes — keeping the high-cardinality, potentially sensitive text out of the indexed attribute set most backends query against. In the official Python instrumentation for OpenAI (opentelemetry-instrumentation-openai-v2, maintained in opentelemetry-python-contrib), the toggle is the environment variable:
# off by default: spans carry token counts and metadata only
# set to "true" to also capture message and completion text as span events
export OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT=true Turn this on in development or a debugging session, never as a standing default in production. If you need it in production for a specific investigation, prefer scoping it to one service, for a bounded time window, and make sure whatever trace backend receives it enforces the same access controls and retention policy you would apply to application logs containing customer data.
Which metrics does the convention define, and what do they answer?
Spans tell you about one call; metrics tell you about your fleet. The convention defines metrics for latency, cost and throughput, each tagged with the same gen_ai.provider.name and gen_ai.operation.name attributes as spans, so you can pivot between a dashboard and a specific trace using the same dimensions.
| Metric | Type | Answers |
|---|---|---|
gen_ai.client.operation.duration | Histogram (s) | End-to-end latency per call, from the client's view |
gen_ai.server.request.duration | Histogram (s) | Server-side request latency, if you operate the inference server |
gen_ai.server.time_to_first_token | Histogram (s) | Perceived responsiveness in a streaming chat UI |
gen_ai.server.time_per_output_token | Histogram (s) | Streaming throughput once generation has started |
gen_ai.client.inference.usage.input_tokens | Counter | Prompt tokens consumed — the input half of your bill |
gen_ai.client.inference.usage.output_tokens | Counter | Completion tokens generated — usually the pricier half |
gen_ai.invoke_agent.duration | Histogram (s) | End-to-end duration of one agent invocation, spanning multiple inference calls |
gen_ai.execute_tool.duration | Histogram (s) | Time spent inside a single tool call during agent execution |
The token usage metrics also carry a gen_ai.token.modality attribute (text, image, audio) and break out cache and reasoning tokens separately — cache_read.input_tokens, cache_write.input_tokens, reasoning.output_tokens — which matters because a cached or reasoning token is typically billed at a different rate than a plain input token. Summed without that split, your cost dashboard quietly overstates or understates spend depending on how much you lean on prompt caching. This slots directly into the cost tracking covered in LLM cost optimization for production apps — the metrics here are the raw numbers that dashboard should be built from, rather than per-provider billing exports.
How do agent and tool calls fit into this?
Agentic workloads add two more operation names and their own span and metric shapes: invoke_agent for one pass through an agent's reasoning loop, and execute_tool for each tool call inside it — carrying gen_ai.agent.name and gen_ai.tool.name respectively. gen_ai.invoke_agent.duration gives you the wall-clock cost of a whole agent turn; gen_ai.execute_tool.duration, tagged per tool, tells you which specific tool is the bottleneck when an agent feels slow. That is the missing piece if you have already built the permission model in building an AI agent for Linux server administration or secured the tools themselves per MCP security: threats and defenses for MCP servers — those cover what an agent is allowed to do; this is how you observe what it actually did and how long each step took.
{
"name": "execute_tool search_knowledge_base",
"kind": "INTERNAL",
"attributes": {
"gen_ai.operation.name": "execute_tool",
"gen_ai.tool.name": "search_knowledge_base",
"gen_ai.tool.type": "function",
"gen_ai.agent.name": "support-triage-agent"
},
"status": {"code": "OK"}
} What does diagnosing a real cost spike look like with these attributes?
A worked example makes this concrete. Say your monthly LLM spend jumps 40% with no feature launch to explain it. Without GenAI semantic conventions, you are reading per-provider billing exports and guessing which service caused it. With them, it is three queries against data you already have.
# 1. Which provider/operation combination grew the most?
sum by (gen_ai_provider_name, gen_ai_operation_name) (
rate(gen_ai_client_inference_usage_output_tokens_total[1d])
)
# 2. Is it token volume, or a shift to a pricier reasoning model?
sum by (gen_ai_response_model) (
rate(gen_ai_client_inference_usage_reasoning_output_tokens_total[1d])
)
# 3. Which service is driving the calls?
topk(5, sum by (service_name, gen_ai_operation_name) (
rate(gen_ai_client_operation_duration_count[1d])
)) In a real instance of this, the second query is usually where the answer shows up: a code change silently switched a model alias to a newer, reasoning-capable version whose reasoning.output_tokens dwarfed the visible completion. Billing exports show the total went up; only the token-type split in gen_ai.usage.* shows why, because reasoning tokens are typically billed at the same rate as output tokens despite never appearing in the response the user sees. Without that attribute split, the investigation stalls at "cost went up" instead of reaching "this one service started requesting extended reasoning on every call."
What should you actually build with this today?
Three concrete steps, in order of effort.
- Use an instrumentation library instead of hand-rolling attributes. For Python and OpenAI,
opentelemetry-instrumentation-openai-v2fromopentelemetry-python-contribis the OpenTelemetry-maintained option;traceloop's OpenLLMetry is a longer-standing community alternative with broader framework coverage. Both wrap the SDK call and emit conforming spans without you writing attribute names by hand — which also means you inherit fixes automatically when the spec's Development-stability attributes change again. - Build one dashboard per provider dimension, not per model. Group by
gen_ai.provider.nameandgen_ai.operation.namefirst; model names change too often to be a stable primary dimension, and the whole point of the convention is that it survives a model swap. - Alert on
gen_ai.client.operation.durationp99 anderror.typerate before you alert on cost. Latency and error-rate regressions are usually the first signal something upstream broke; cost anomalies often follow an hour or a day later and are better caught by a scheduled report than a real-time alert.
Treat the Development stability label as real: pin your instrumentation library version, read its changelog before upgrading, and expect at least one more attribute rename before this reaches Stable. The gen_ai.system to gen_ai.provider.name change already happened once; building dashboards against the attribute names directly, rather than through an abstraction layer, means you will redo that work again.
If you want LLM observability built properly from day one — instrumentation, dashboards, cost attribution and alerting designed around the current spec rather than copied from a stale tutorial — my DevOps and cloud consulting services cover exactly this.