OpenTelemetry GenAI Semantic Conventions: Observability for LLM Apps (2026)

Khimananda Oli 13 min read Database, Virtualization
OpenTelemetry GenAI Semantic Conventions: Observability for LLM Apps (2026)

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.

AttributeStatusUse instead
gen_ai.systemDeprecatedgen_ai.provider.name
gen_ai.provider.nameCurrent (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.

One inference span, fully attributedSpan name: {gen_ai.operation.name} {gen_ai.request.model}chat gpt-5-mini-2026-08-01Requiredgen_ai.operation.name"chat"gen_ai.provider.name"openai"Every GenAI span needs both,or it is not a conforming span.gen_ai.system is deprecated.Conditionally requirederror.typeon failuregen_ai.request.modelif knownserver.address / portif applicableerror.type should match the provider'sown error code or exception name.Recommendedgen_ai.response.modelgen_ai.usage.input_tokensgen_ai.usage.output_tokensserver.addressThe request and response model canNot shown on the span by default: the prompt and completion textContent capture is opt-in — covered in the next sectionthis keeps API keys, customer PII and proprietary prompts out of your trace backend by default
A conforming GenAI inference span: the span name pattern plus its required, conditionally required and recommended gen_ai attributes — note gen_ai.provider.name, not the deprecated gen_ai.system.

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.

MetricTypeAnswers
gen_ai.client.operation.durationHistogram (s)End-to-end latency per call, from the client's view
gen_ai.server.request.durationHistogram (s)Server-side request latency, if you operate the inference server
gen_ai.server.time_to_first_tokenHistogram (s)Perceived responsiveness in a streaming chat UI
gen_ai.server.time_per_output_tokenHistogram (s)Streaming throughput once generation has started
gen_ai.client.inference.usage.input_tokensCounterPrompt tokens consumed — the input half of your bill
gen_ai.client.inference.usage.output_tokensCounterCompletion tokens generated — usually the pricier half
gen_ai.invoke_agent.durationHistogram (s)End-to-end duration of one agent invocation, spanning multiple inference calls
gen_ai.execute_tool.durationHistogram (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.

One pipeline, shared attributes throughoutThe same gen_ai.provider.name and gen_ai.operation.name tag every signalApp codeopenai.chat.completions.create(...)GenAI instrumentationwraps the SDK call,emits span + metricsOTel Collectorbatches, redacts,routes by signal typeYour backendtraces, dashboards,alertsOne span per callgen_ai.provider.name = "openai"gen_ai.usage.input_tokens = 842duration = 1.8sAnswers: what happened onthis one request, and whydid it fail or run slow?Aggregated metricsgen_ai.client.operation.durationgen_ai.client.inference.usage.*gen_ai.server.time_to_first_tokenAnswers: is p99 latencydegrading, and is thismonth's token spend up?
GenAI spans and metrics share the same gen_ai.provider.name and gen_ai.operation.name attributes, so one trace explains a single slow call and one dashboard tracks the whole fleet.

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

From "cost went up" to "this is why"Three queries against attributes you already collect1 · Which provider/op?group by gen_ai.provider.name,gen_ai.operation.nameNarrows a fleet-wide spike toone provider and call type2 · Model or token mix?gen_ai.response.model vsgen_ai.usage.reasoning.output_tokensReveals a silent switch to apricier reasoning model3 · Which service?group by service.name,gen_ai.operation.namePoints at the exact code pathto fixWithout the token-type splitA billing export shows total spend up 40%.It cannot show that reasoning tokens — billed like output tokens,but invisible in the user-facing response — are the real driver.The investigation stalls at "cost went up," not "why."
Diagnosing an LLM cost spike with GenAI semantic conventions: provider and operation narrow the search, the token-type split explains the mechanism, and the service attribute finds the fix.

What should you actually build with this today?

Three concrete steps, in order of effort.

  1. Use an instrumentation library instead of hand-rolling attributes. For Python and OpenAI, opentelemetry-instrumentation-openai-v2 from opentelemetry-python-contrib is 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.
  2. Build one dashboard per provider dimension, not per model. Group by gen_ai.provider.name and gen_ai.operation.name first; 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.
  3. Alert on gen_ai.client.operation.duration p99 and error.type rate 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.

Frequently Asked Questions

A standard set of attribute names, under the gen_ai.* namespace, for spans, metrics and events describing calls to large language models and AI agents. They let a trace or dashboard look the same regardless of which provider — OpenAI, Anthropic, a self-hosted model — actually served the request.

gen_ai.provider.name. gen_ai.system is deprecated; new instrumentation should use gen_ai.provider.name, which takes values such as openai, anthropic, aws.bedrock and azure.ai.openai, with a custom string allowed for providers not yet listed.

{gen_ai.operation.name} {gen_ai.request.model}, for example "chat gpt-5-mini-2026-08-01". Retrieval spans instead use {gen_ai.operation.name} {gen_ai.data_source.id}.

gen_ai.operation.name and gen_ai.provider.name are Required on every GenAI span. error.type, gen_ai.request.model and server address/port are Conditionally Required. gen_ai.response.model and the usage token counts are Recommended but not mandatory for conformance.

No. The specification says instrumentations should not capture prompt and completion content by default, and must provide an explicit opt-in. This prevents customer data, proprietary prompts and accidentally pasted secrets from reaching your trace backend by default.

For the official Python OpenAI instrumentation, set the environment variable OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT=true. It is off by default; enable it only for development or a bounded debugging window, never as a standing production default.

Latency metrics (gen_ai.client.operation.duration, gen_ai.server.time_to_first_token, gen_ai.server.time_per_output_token), token usage counters (gen_ai.client.inference.usage.input_tokens and output_tokens, including separate cache and reasoning token counts), and agent/tool metrics such as gen_ai.invoke_agent.duration and gen_ai.execute_tool.duration.

Because providers typically bill them at different rates than ordinary input or output tokens. A cost dashboard that sums all tokens into one bucket without separating cache reads, cache writes and reasoning tokens will misreport actual spend.

chat, text_completion, embeddings, generate_content, create_agent, invoke_agent, invoke_workflow, execute_tool and retrieval. Each has its own span naming pattern and attribute set.

No, they carry Development stability as of late 2026, and the specification now lives in its own dedicated repository separate from the main OpenTelemetry semantic conventions. Attribute names can still change — the gen_ai.system to gen_ai.provider.name rename already happened once.

opentelemetry-instrumentation-openai-v2, maintained in the official opentelemetry-python-contrib repository, is the OpenTelemetry-maintained option for Python. Traceloop's OpenLLMetry is a longer-standing community alternative with broader framework coverage.

Each tool call gets its own span with gen_ai.operation.name set to execute_tool, carrying gen_ai.tool.name and gen_ai.agent.name. gen_ai.execute_tool.duration, tagged per tool, shows which specific tool is slowing an agent down.

It should not. error.type is meant to match the provider's error code or the exception's class name — a low-cardinality identifier — rather than a raw free-text error message, which would blow up cardinality in most trace backends.

Not as the primary dimension. Model names and versions change frequently; group first by gen_ai.provider.name and gen_ai.operation.name, which stay stable across model swaps, and use the model name as a secondary filter.

Tool-call spans (execute_tool) are what you would instrument around MCP tool invocations specifically. Securing those same tool calls — pinning servers, scoping credentials, approving writes — is a separate concern covered in MCP security guidance, not something the semantic conventions handle.