# OpenTelemetry GenAI Semantic Conventions: A Practical Guide

> A practical, attribute-by-attribute guide to OpenTelemetry's GenAI semantic conventions: the gen_ai.* namespace for spans, metrics, and events, stability levels, a worked instrumentation example, and common migration mistakes.

Source: https://openobserve.ai/blog/opentelemetry-genai-semantic-conventions/
Published: 2026-08-11
Authors: Simran Kumari
Category: Engineering
Tags: OpenTelemetry, AI, Observability, DevOps

---

Two engineers instrument the same OpenAI call. One names the attribute `model`, the other `llm_model_name`, a third framework calls it `openai.request.model`. None of these compose into one dashboard. That's the exact problem OpenTelemetry's GenAI semantic conventions exist to solve: a single, standardized `gen_ai.*` vocabulary so a chat span from LangChain, a raw OpenAI SDK call, and a Claude agent all speak the same attribute names.

This is a practical, attribute-by-attribute reference, not just a definition. It covers the actual `gen_ai.*` names you'll write and query, which ones are stable enough to build a dashboard on today, and a worked example you can copy directly into an instrumentation script.

For the broader context of why LLM observability differs from traditional APM, see [OpenTelemetry for LLMs](https://openobserve.ai/blog/opentelemetry-for-llms/); for semantic conventions outside the GenAI namespace (HTTP, database, resource attributes), see [OpenTelemetry Semantic Conventions Explained](https://openobserve.ai/blog/opentelemetry-semantic-conventions/).

## TL;DR

OpenTelemetry's GenAI semantic conventions standardize LLM and AI agent telemetry under a single `gen_ai.*` namespace, covering span attributes (`gen_ai.request.model`, `gen_ai.usage.input_tokens`), metrics (`gen_ai.client.token.usage`, `gen_ai.client.operation.duration`), and opt-in content events for prompts and responses. Core chat attributes are mature enough for production dashboards; agent and multi-agent conventions are still evolving.

- **Namespace**: everything lives under `gen_ai.*`, so it's filterable as one group across spans, metrics, and logs.
- **Core span attributes**: `gen_ai.operation.name`, `gen_ai.provider.name`, `gen_ai.request.model`, `gen_ai.response.model`, `gen_ai.usage.input_tokens`, `gen_ai.usage.output_tokens`.
- **Content is opt-in**: prompt and response bodies aren't in standard attributes by default; they're separate, explicitly-enabled events, because they routinely contain PII.
- **Metrics**: two standardized histogram instruments cover token usage and operation duration, enough for p95 latency and cost SLOs.
- **Stability varies by attribute**: chat and embeddings are mature; agent and tool-orchestration conventions are still marked Development and change more often.

## What Are OpenTelemetry GenAI Semantic Conventions?

[Semantic conventions](https://opentelemetry.io/docs/specs/semconv/) are OpenTelemetry's naming rules: standardized attribute names and value formats so telemetry means the same thing regardless of who instrumented it. The [GenAI semantic conventions](https://opentelemetry.io/docs/specs/semconv/gen-ai/) apply that idea to LLM and AI agent workloads specifically, defined by the OTel [GenAI Special Interest Group](https://github.com/open-telemetry/community/blob/main/projects/gen-ai.md) since April 2024.

Concretely, that means: when any properly-instrumented library makes a chat completion call, the model name lands in `gen_ai.request.model`, not `model`, not `modelName`, not `llm_model`. Every OTel-instrumented LLM library that follows the spec produces that exact attribute name.

The conventions cover three telemetry types:

* **Spans**: one span per LLM operation (chat call, embedding generation, tool invocation, agent step), carrying request and response metadata as attributes
* **Metrics**: two standardized instruments for token usage and operation duration, aggregatable across providers and models
* **Events**: opt-in, separately-captured prompt and response content, kept out of standard attributes specifically because of PII exposure risk

## Why GenAI Traffic Needed Its Own Semantic Conventions

The existing HTTP and database conventions don't fit LLM calls, for three concrete reasons:

**1. Billing is per-token, not per-request.** A traditional HTTP span cares about status code and duration. An LLM span needs input and output token counts, because that's what determines cost, and no existing convention had a place for that.

**2. Request parameters affect output, not just performance.** `temperature`, `top_p`, and `max_tokens` change what the model returns, not just how fast. Standard HTTP conventions have nothing analogous; a query parameter doesn't usually change response semantics the way a sampling temperature does.

**3. The content itself is often the thing you need to debug.** Debugging a slow database query rarely requires reading the row data. Debugging a bad LLM response usually requires reading the actual prompt and completion, which is exactly the sensitive data standard conventions are designed to avoid capturing by default.

## Stability Status: What You Can Rely On Today

Every attribute in the spec carries a stability marker, the same three-tier system used across all OpenTelemetry semantic conventions: **Development**, **Release Candidate**, and **Stable**. GenAI conventions are younger than HTTP or database conventions, so stability varies more by section:

| Area | Typical Stability | What That Means |
| --- | --- | --- |
| Core chat/completion attributes (`gen_ai.request.model`, `gen_ai.usage.*`) | Stable / Release Candidate | Safe to build permanent dashboards and alerts on |
| Embeddings attributes | Release Candidate | Mostly settled, minor naming changes still possible |
| Tool and function call attributes | Development | Usable, but expect attribute renames as multi-tool agent patterns mature |
| Agent and multi-agent attributes (`gen_ai.agent.*`) | Development | Actively being designed; treat as provisional |

Check the [live spec](https://opentelemetry.io/docs/specs/semconv/gen-ai/) before locking in a dashboard on anything from the agent or tool-orchestration sections, since those are the parts most likely to change between OTel releases.

## The Core gen_ai.* Attributes Every Span Should Have

These are the attributes that show up on essentially every LLM span, regardless of provider or operation type:

| Attribute | Type | What It Tells You |
| --- | --- | --- |
| `gen_ai.operation.name` | string | The operation type: `chat`, `text_completion`, `embeddings`, `execute_tool`, `create_agent`, `invoke_agent` |
| `gen_ai.provider.name` | string | Which provider handled the call: `openai`, `anthropic`, `azure.ai.openai`, and so on (see the `gen_ai.system` migration note below) |
| `gen_ai.request.model` | string | The model requested, e.g. `gpt-4o`, `claude-opus-4` |
| `gen_ai.response.model` | string | The model that actually served the response, which can differ from the request if the provider routed to a specific dated snapshot |
| `gen_ai.usage.input_tokens` | int | Prompt token count, the primary cost driver on the request side |
| `gen_ai.usage.output_tokens` | int | Completion token count, the primary cost driver on the response side |
| `gen_ai.request.temperature` | double | Sampling temperature; correlate with quality regressions over time |
| `gen_ai.request.top_p` | double | Nucleus sampling parameter; affects output diversity |
| `gen_ai.request.max_tokens` | int | The cap requested; useful for spotting truncated responses |
| `gen_ai.response.finish_reasons` | string[] | Why generation stopped, as an array (`["stop"]`, `["tool_use"]`, `["length"]`), since a response can technically have multiple choices |
| `gen_ai.response.id` | string | Provider-assigned response ID, useful for correlating with provider-side logs or support tickets |
| `gen_ai.conversation.id` | string | Groups every span belonging to one logical session or multi-turn conversation, the attribute OpenObserve's LLM Sessions view keys on |
| `error.type` | string | Rate limits, timeouts, content policy rejections, the GenAI equivalent of a 5xx |

Note the plural on `gen_ai.response.finish_reasons`: it's an array, not a single string, even though a typical single-response chat call populates it with exactly one value.

## Span Conventions by Operation Type

`gen_ai.operation.name` determines which additional attributes apply. The core set above covers `chat`, but other operation types add their own:

**Embeddings** (`gen_ai.operation.name: "embeddings"`):

* `gen_ai.request.encoding_formats`: requested output format(s), e.g. `["float"]`
* `gen_ai.embeddings.dimension.count`: dimensionality of the returned vectors

**Tool / function calls** (`gen_ai.operation.name: "execute_tool"`):

* `gen_ai.tool.name`: the tool or function being invoked
* `gen_ai.tool.call.id`: a unique ID for this specific call, useful for matching a tool call span to its result
* `gen_ai.tool.description`: what the tool does, if available

**Agents** (`gen_ai.operation.name: "create_agent"` / `"invoke_agent"`), still Development-stability:

* `gen_ai.agent.name` / `gen_ai.agent.id`: which agent handled the step
* `gen_ai.agent.description`: the agent's stated purpose
* `gen_ai.agent_version`: version string for the agent implementation, useful for correlating a quality regression with a specific agent deploy

Span naming follows the pattern `{gen_ai.operation.name} {gen_ai.request.model}` where practical, for example a chat call to `gpt-4o` produces a span named `chat gpt-4o`, so a trace waterfall reads as a sequence of operations without needing to open each span to see what it did.

## Metric Conventions

Two standardized histogram instruments cover the numbers you actually alert and SLO on:

```yaml
gen_ai.client.token.usage              → histogram (tokens per request)
gen_ai.client.operation.duration       → histogram (latency in seconds)
```

Both carry the same core attributes as spans (`gen_ai.provider.name`, `gen_ai.request.model`, `gen_ai.operation.name`), so they're aggregatable by provider, model, or operation type without needing to join against span data.

Recommended SLO dimensions built from these two instruments:

* p95 `gen_ai.client.operation.duration` grouped by `(gen_ai.provider.name, gen_ai.request.model)`
* Token budget utilization per team or feature, using `gen_ai.client.token.usage` with a custom resource attribute for team/feature
* Error rate by `error.type`
* Cost per 1,000 requests: multiply `gen_ai.client.token.usage` by your provider's per-token pricing in a dashboard panel

See the [official GenAI metrics reference](https://opentelemetry.io/docs/specs/semconv/gen-ai/gen-ai-metrics/) for the complete instrument list, including experimental ones still moving through the stability process.

## Capturing Prompt and Response Content

This is the part every team gets wrong at least once: **prompt and response bodies are not standard span attributes.** The spec deliberately keeps them out of the default attribute set, because unlike a model name or token count, message content routinely contains names, emails, account numbers, or proprietary business logic typed directly by a user.

Instead, content capture is modeled as a separate, opt-in mechanism: structured log-based events, correlated back to the originating span via `trace_id` and `span_id`, only emitted when explicitly enabled (commonly via an environment variable like `OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT` in current instrumentation libraries).

Practical pattern:

1. Leave content capture off by default in production.
2. When you do enable it, route those log events through an OTel Collector redaction processor before they reach storage.
3. Query captured content by pivoting from a span's `trace_id` into the log stream, rather than expecting it to appear as a span attribute.

For the redaction mechanics specifically, see [Redacting PII from LLM Telemetry](https://openobserve.ai/blog/redact-pii-llm-telemetry/) and [Filter Logs at Source in OTel Collector](https://openobserve.ai/blog/filter-logs-at-source-in-otel-collector/) for the processor patterns this relies on.

## A Complete Worked Example

Here's what a fully-populated chat span looks like end to end, combining the core attributes above into one realistic example:

```yaml
Span name: chat gpt-4o

Attributes:
  gen_ai.operation.name: "chat"
  gen_ai.provider.name: "openai"
  gen_ai.request.model: "gpt-4o"
  gen_ai.response.model: "gpt-4o-2024-08-06"
  gen_ai.response.id: "chatcmpl-abc123"
  gen_ai.usage.input_tokens: 312
  gen_ai.usage.output_tokens: 148
  gen_ai.request.temperature: 0.7
  gen_ai.request.top_p: 0.9
  gen_ai.request.max_tokens: 1024
  gen_ai.response.finish_reasons: ["stop"]
  gen_ai.conversation.id: "conv_9f2a1b"
  server.address: "api.openai.com"
```

And the equivalent instrumentation in Python, using the official OpenTelemetry OpenAI instrumentation:

```python
from opentelemetry.instrumentation.openai import OpenAIInstrumentor
from opentelemetry import trace

# Instrument before any LLM calls are made
OpenAIInstrumentor().instrument()

from openai import OpenAI
client = OpenAI()

response = client.chat.completions.create(
    model="gpt-4o",
    temperature=0.7,
    top_p=0.9,
    max_tokens=1024,
    messages=[{"role": "user", "content": "Summarize this incident report."}],
)
# The span above is created and populated automatically:
# gen_ai.request.model, gen_ai.usage.*, gen_ai.response.finish_reasons,
# and the rest are attached with zero manual span code.
```

If you're instrumenting manually (no auto-instrumentation library available for your provider), the same attributes apply; you're just setting them yourself on a span you create explicitly:

```python
from opentelemetry import trace

tracer = trace.get_tracer(__name__)

with tracer.start_as_current_span("chat gpt-4o") as span:
    span.set_attribute("gen_ai.operation.name", "chat")
    span.set_attribute("gen_ai.provider.name", "openai")
    span.set_attribute("gen_ai.request.model", "gpt-4o")
    # ... make the actual API call ...
    span.set_attribute("gen_ai.response.model", response.model)
    span.set_attribute("gen_ai.usage.input_tokens", response.usage.prompt_tokens)
    span.set_attribute("gen_ai.usage.output_tokens", response.usage.completion_tokens)
    span.set_attribute("gen_ai.response.finish_reasons", [response.choices[0].finish_reason])
```

## Migrating from gen_ai.system or Provider-Specific Attributes

If your instrumentation predates the current spec, or you're consolidating traces from multiple libraries, you'll run into naming drift. The most common migration:

* **`gen_ai.system` → `gen_ai.provider.name`**: the original provider-identification attribute is being superseded by `gen_ai.provider.name`. Many current instrumentation libraries emit both during the transition, so don't be surprised to see either in production traces; write queries that check both until your whole fleet is on updated libraries.
* **`llm.request.model` / `llm.usage.prompt_tokens` (older, pre-standardization naming)** → `gen_ai.request.model` / `gen_ai.usage.input_tokens`: some earlier LLM-specific instrumentation used an `llm.*` namespace before the GenAI SIG's conventions solidified. If you have telemetry from that era, either re-instrument with a current library or add an OTel Collector `attributes` processor to rename fields into the current `gen_ai.*` names at ingestion.
* **Provider SDK-native field names** (e.g. a raw OpenAI response's `usage.prompt_tokens`) **→ `gen_ai.usage.input_tokens`**: don't ship provider SDK field names directly into your telemetry; map them to the standard attribute names so a Claude call and a GPT call land in the same dashboard column.

## The GenAI Instrumentation Ecosystem

You rarely hand-write every attribute. These libraries auto-instrument popular providers and frameworks to the GenAI conventions:

| Library | Coverage |
| --- | --- |
| Official OTel contrib (`opentelemetry-instrumentation-openai`, `-anthropic`) | OpenAI and Anthropic clients, Python and JavaScript |
| [OpenLIT](https://github.com/openlit/openlit) | OpenAI, Anthropic, LangChain, LlamaIndex, and popular vector databases |
| [OpenLLMetry](https://github.com/traceloop/openllmetry) | Broader framework and agent coverage, plus a gateway proxy for centralized instrumentation |

Whichever library you pick, the payoff of standardized conventions is that the backend doesn't care which one produced the span, `gen_ai.request.model` means the same thing regardless of source. See [OpenTelemetry for LLMs](https://openobserve.ai/blog/opentelemetry-for-llms/) for a full instrumentation walkthrough, and [Top 10 LLM Observability Tools in 2026](https://openobserve.ai/blog/top-10-llm-observability-tools/) for how different backends handle this data once it arrives.

## Common Mistakes When Adopting GenAI Semantic Conventions

1. **Inventing custom attribute names instead of using the spec.** The entire point is cross-tool consistency; a custom `model_name` attribute breaks every dashboard built against the standard `gen_ai.request.model`.
2. **Treating `gen_ai.response.finish_reasons` as a single string.** It's an array. Code that expects a scalar will break or silently mis-parse it.
3. **Capturing prompt/response content by default.** Leaving content capture always-on ships PII into your telemetry backend as a matter of routine, not exception.
4. **Building permanent dashboards on Development-status agent attributes.** Those names are still being finalized; expect churn if you depend on them today.
5. **Ignoring `gen_ai.conversation.id`.** Without it, multi-turn conversations and agent loops show up as disconnected individual spans instead of one traceable session, which is exactly the view you need to catch a runaway agent loop; see [Tracing a Runaway LLM Token Spike](https://openobserve.ai/blog/tracing-a-runaway-llm-token-spike-from-session-to-trace-to-rum/) for what that investigation looks like when the session grouping is in place.

## Seeing GenAI Semantic Conventions in Practice

Here's what these attributes actually look like once they land in a real backend, an OpenObserve trace for a Claude Code agent interaction, with the full `gen_ai_*` attribute set expanded (OpenObserve replaces dots with underscores in field names, so `gen_ai.usage.input_tokens` appears as `gen_ai_usage_input_tokens`):

![Real OpenObserve trace span for a Claude agent interaction with gen_ai_operation_name, gen_ai_provider_name, gen_ai_request_model, gen_ai_response_finish_reasons as an array, and gen_ai_usage token attributes all visible](/assets/blog/opentelemetry-semantic-conventions/genai_semantic_conventions_in_openobserve.png)

Notice `gen_ai_response_finish_reasons` shown as `["tool_use"]`, confirming the array format, `gen_ai_conversation_id` grouping this span into its parent session, and both `gen_ai_system` and `gen_ai_provider_name` present side by side, exactly the migration overlap described above.

## Conclusion

OpenTelemetry's GenAI semantic conventions solve one specific problem: making `gen_ai.*` attributes mean the same thing no matter which library, language, or provider produced them. Core chat and embeddings attributes are stable enough to build production dashboards on today; agent and tool-orchestration conventions are still settling, so treat those as provisional. Standardize on the spec's attribute names instead of inventing your own, keep prompt content opt-in and redacted, and use `gen_ai.conversation.id` to keep multi-turn sessions traceable as a unit.

OpenObserve ingests `gen_ai.*` spans, metrics, and events natively over OTLP, no custom attribute mapping required, so instrumentation that follows the spec works against it as-is.

[Try OpenObserve free →](https://cloud.openobserve.ai)
