An LLM feature in production fails in ways a normal service does not. The HTTP call returns 200 and the answer is wrong. Latency doubles because a prompt grew by 30K tokens. The monthly bill jumps because one agent started looping through a tool eight times instead of two. None of that shows up in a dashboard that only tracks request rate, errors and p95 latency.
LLM observability is the set of signals that make these failures visible: traces of every model call and tool call inside a request, token counts, cost, time to first token, finish reasons, and (carefully) the content itself. The good news is that you do not need a new stack for it. OpenTelemetry has a set of semantic conventions for generative AI that define the span names, attribute names and metrics, so an LLM call can live in the same trace as the database query and the HTTP request around it.
This cheatsheet covers those conventions as of release v1.38.0 (October 29, 2025), how to model LLM calls, tools and agents as spans, how to account for tokens and cost, which latencies to measure, how to sample and redact, and what the main tools do. It is about runtime telemetry, not quality evaluation.
1. What to observe
| Signal | Question it answers | Where it lives |
|---|---|---|
| Trace of a request | What did this request do, step by step, and where did time go? | Spans: LLM calls, tool calls, retrieval, agent steps, plus regular HTTP and DB spans |
| Token usage | How many input and output tokens per call, per feature, per user? | Span attributes and a histogram metric |
| Cost | What did this request, feature or customer cost? | Derived from tokens and your price table |
| Latency | Total duration, time to first token, time per output token | Span duration and histogram metrics |
| Finish reason | Did the model stop normally, hit the token limit, or stop for a tool call or content filter? | Span attribute |
| Errors | Rate limits, timeouts, invalid tool arguments, refusals | Span status and error.type |
| Content | What exactly did the model see and say? | Opt-in attributes, events, or external storage with references |
The first six are cheap, safe and should be on for every call. Content is the most useful for debugging and the most dangerous to store (section 9).
2. OpenTelemetry GenAI semantic conventions: status
The GenAI conventions are part of the OpenTelemetry semantic conventions repository. Every GenAI attribute, span and metric is marked Development (experimental). Names have changed between releases and may change again.
What changed recently, and why it matters if you read older blog posts or dashboards:
| Release | Date | GenAI changes |
|---|---|---|
| v1.37.0 | August 25, 2025 | gen_ai.system renamed to gen_ai.provider.name. Chat history moved from per-message events to the attributes gen_ai.system_instructions, gen_ai.input.messages, gen_ai.output.messages; the old message events were deprecated |
| v1.38.0 | October 29, 2025 | Tool definitions and tool call arguments and results documented (gen_ai.tool.definitions, gen_ai.tool.call.arguments, gen_ai.tool.call.result), an evaluation event, gen_ai.embeddings.dimension.count, reasoning and multimodal parts in the message JSON schemas |
The spec includes a transition plan for this churn. Instrumentations that emitted v1.36.0 or earlier should not change their default output, and should support OTEL_SEMCONV_STABILITY_OPT_IN=gen_ai_latest_experimental to emit the latest experimental version instead. In practice: pin your instrumentation versions, check which convention version they emit, and expect to update dashboards when you upgrade.
3. Spans for LLM calls (inference)
| Item | Convention |
|---|---|
| Span name | {gen_ai.operation.name} {gen_ai.request.model}, e.g. chat gpt-4o |
| Span kind | CLIENT (may be INTERNAL for models running in the same process) |
| Operation names | chat, text_completion, generate_content, embeddings, create_agent, invoke_agent, execute_tool |
| Errors | Span status per OTel error recording rules, plus error.type |
Key attributes, with their requirement level in v1.38.0:
| Attribute | Level | Example |
|---|---|---|
gen_ai.operation.name |
Required | chat |
gen_ai.provider.name |
Required | openai, anthropic, aws.bedrock, gcp.vertex_ai |
gen_ai.request.model |
Conditionally required | gpt-4o |
gen_ai.conversation.id |
Conditionally required, when available | session or thread ID |
gen_ai.output.type |
Conditionally required | text, json, image |
gen_ai.request.max_tokens, .temperature, .top_p, .top_k, .seed, .stop_sequences |
Recommended (seed: conditionally required) | 256, 0.0 |
gen_ai.response.model |
Recommended | gpt-4o-2024-08-06 |
gen_ai.response.id |
Recommended | chatcmpl-123 |
gen_ai.response.finish_reasons |
Recommended | ["stop"], ["length"] |
gen_ai.usage.input_tokens |
Recommended | 412 |
gen_ai.usage.output_tokens |
Recommended | 180 |
server.address, server.port |
Recommended | api.openai.com, 443 |
gen_ai.system_instructions, gen_ai.input.messages, gen_ai.output.messages |
Opt-in | structured JSON |
gen_ai.tool.definitions |
Opt-in | list of tool schemas |
Two details worth noticing:
- Request model vs response model. You ask for an alias; the provider answers with a specific snapshot. Record both. When quality or latency shifts overnight,
gen_ai.response.modelis often the explanation. - Finish reasons are a cheap, high-value signal. A rising share of
lengthmeans truncated answers; a rise in content-filter stops means users or prompts changed.
4. Spans for tools and agents
An agent request is a tree. The conventions give you names for each node:
invoke_agent support-agent (CLIENT or INTERNAL)
├── chat gpt-4o (CLIENT) model decides to call a tool
├── execute_tool search_orders (INTERNAL)
│ └── GET /orders/1234 (regular HTTP client span)
├── chat gpt-4o (CLIENT) model reads the result
└── execute_tool issue_refund (INTERNAL)
| Span | Name | Kind | Key attributes |
|---|---|---|---|
| Create agent | create_agent {gen_ai.agent.name} |
CLIENT |
gen_ai.agent.name, gen_ai.request.model |
| Invoke agent | invoke_agent {gen_ai.agent.name} (or invoke_agent) |
CLIENT, or INTERNAL for in-process agents |
gen_ai.agent.id, gen_ai.agent.name, gen_ai.agent.description, gen_ai.conversation.id, gen_ai.data_source.id, usage tokens |
| Execute tool | execute_tool {gen_ai.tool.name} |
INTERNAL |
gen_ai.tool.name, gen_ai.tool.call.id, gen_ai.tool.type (function, extension, datastore), gen_ai.tool.description; opt-in gen_ai.tool.call.arguments and gen_ai.tool.call.result |
What this buys you:
- Loops become visible. Count
execute_toolspans perinvoke_agentspan. An agent that normally uses three tool calls and suddenly uses twelve is stuck. - Tool latency vs model latency. Often the slow part of an "LLM request" is a tool, not the model.
- Tool errors in context. Invalid arguments, empty results and timeouts sit next to the model call that produced them, with the same
gen_ai.tool.call.id.
5. Metrics
The conventions define histograms so you do not have to aggregate from spans:
| Metric | Type, unit | Level | Notes |
|---|---|---|---|
gen_ai.client.token.usage |
Histogram, {token} |
Recommended | gen_ai.token.type = input or output; suggested buckets from 1 to 67,108,864 in powers of 4 |
gen_ai.client.operation.duration |
Histogram, s |
Required | Suggested buckets from 0.01 s to 81.92 s |
gen_ai.server.request.duration |
Histogram, s |
Recommended | For model servers you run |
gen_ai.server.time_to_first_token |
Histogram, s |
Recommended | Server side TTFT |
gen_ai.server.time_per_output_token |
Histogram, s |
Recommended | Decode speed after the first token |
All of them carry gen_ai.operation.name, gen_ai.provider.name, and the request and response model, so you can break down by model and provider. Do not put user IDs or conversation IDs on metrics; high-cardinality labels belong on spans.
6. Tokens and cost
Tokens are the unit of both cost and capacity. Record them from the provider's response (usage), not from your own tokenizer estimate.
| Practice | Why |
|---|---|
| Record input and output tokens on every LLM span | Output tokens are usually priced higher than input tokens; you need both |
| Compute cost at write time and store it as a span attribute | Prices change; the cost of a past request should not |
| Keep a versioned price table in config, keyed by response model | Snapshots of the same model family can have different prices |
| Aggregate cost up the trace | Cost per request, per agent run, per feature and per tenant is what finance and product ask for |
| Track cached and reasoning tokens separately when the provider reports them | Cached input tokens are often discounted; reasoning tokens are typically billed as output even when not returned |
As of v1.38.0 there is no standard attribute for cost, cached input tokens or reasoning tokens. Use your own namespace (for example app.llm.cost_usd, app.llm.cached_input_tokens) so they do not collide with future official names, and document them.
Cost per request is simple arithmetic:
cost = input_tokens * input_price_per_token
+ output_tokens * output_price_per_token
(- cached_input_tokens * discount, if your provider reports them)
Useful alerts: cost per request p95 by feature, tokens per agent run, and the ratio of input tokens to output tokens (a sudden jump in input usually means context or retrieval grew).
7. Latency: duration, TTFT, tokens per second
For streaming responses, total duration is the wrong number to optimize alone. Users perceive:
| Measure | Definition | What moves it |
|---|---|---|
| Time to first token (TTFT) | Request sent to first content token received | Input length, queueing, prompt caching, model size, region |
| Time per output token | Average time between tokens after the first | Model size, provider load, hardware |
| Total duration | Request sent to last token | TTFT + output tokens x time per token, plus tool calls in agents |
The conventions define TTFT as a server metric. For client-side TTFT (what your users experience through a provider API) there is no standard attribute in v1.38.0, so measure it in your client and record it as a custom attribute or metric. Always report latency per model and per output-length bucket; averages across very different requests hide everything.
8. A small Python example
Plain OpenTelemetry SDK, no vendor library. A fake streaming client stands in for a provider so the example runs as is (pip install opentelemetry-sdk). Prices are illustrative.
import time
from opentelemetry import metrics, trace
from opentelemetry.sdk.metrics import MeterProvider
from opentelemetry.sdk.metrics.export import ConsoleMetricExporter, PeriodicExportingMetricReader
from opentelemetry.sdk.resources import Resource
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import BatchSpanProcessor, ConsoleSpanExporter
from opentelemetry.trace import SpanKind, Status, StatusCode
resource = Resource.create({"service.name": "support-bot"})
tp = TracerProvider(resource=resource)
tp.add_span_processor(BatchSpanProcessor(ConsoleSpanExporter()))
trace.set_tracer_provider(tp)
mp = MeterProvider(resource=resource,
metric_readers=[PeriodicExportingMetricReader(ConsoleMetricExporter())])
metrics.set_meter_provider(mp)
tracer = trace.get_tracer("support-bot.llm")
meter = metrics.get_meter("support-bot.llm")
token_usage = meter.create_histogram("gen_ai.client.token.usage", unit="{token}")
duration = meter.create_histogram("gen_ai.client.operation.duration", unit="s")
PRICES = {"example-model": {"input": 0.50, "output": 2.00}} # USD per 1M tokens, illustrative
def fake_stream(prompt):
"""Stand-in for a provider streaming call: text chunks, then a usage record."""
for word in ["Your", " refund", " is", " on", " its", " way."]:
time.sleep(0.05)
yield word
yield {"input_tokens": 412, "output_tokens": 6, "response_model": "example-model-2025-10-01"}
def chat(model: str, prompt: str) -> str:
base = {"gen_ai.operation.name": "chat",
"gen_ai.provider.name": "openai",
"gen_ai.request.model": model}
start = time.perf_counter()
with tracer.start_as_current_span(f"chat {model}", kind=SpanKind.CLIENT,
attributes=base) as span:
span.set_attribute("gen_ai.request.max_tokens", 256)
parts, usage, ttft = [], {}, None
try:
for chunk in fake_stream(prompt):
if isinstance(chunk, dict):
usage = chunk
continue
if ttft is None:
ttft = time.perf_counter() - start
span.set_attribute("app.llm.time_to_first_token", ttft) # custom
parts.append(chunk)
except Exception as exc:
span.set_status(Status(StatusCode.ERROR))
span.set_attribute("error.type", type(exc).__qualname__)
raise
span.set_attribute("gen_ai.response.model", usage["response_model"])
span.set_attribute("gen_ai.usage.input_tokens", usage["input_tokens"])
span.set_attribute("gen_ai.usage.output_tokens", usage["output_tokens"])
span.set_attribute("gen_ai.response.finish_reasons", ["stop"])
price = PRICES[model]
cost = (usage["input_tokens"] * price["input"]
+ usage["output_tokens"] * price["output"]) / 1_000_000
span.set_attribute("app.llm.cost_usd", round(cost, 8)) # custom
duration.record(time.perf_counter() - start, base)
token_usage.record(usage["input_tokens"], {**base, "gen_ai.token.type": "input"})
token_usage.record(usage["output_tokens"], {**base, "gen_ai.token.type": "output"})
return "".join(parts)
print(chat("example-model", "Where is my refund?"))
tp.shutdown()
mp.shutdown()
The console exporter prints a span named chat example-model with kind CLIENT, the gen_ai.* attributes, a TTFT of about 0.05 s and a cost of 0.000218 USD, followed by the two histograms. Swap the console exporters for OTLP exporters to send the same data to any OTel-compatible backend.
If you use the OpenAI Python SDK, the contrib package opentelemetry-instrumentation-openai-v2 produces these spans for you. Run with opentelemetry-instrument python main.py, and content capture stays off unless you set OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT=true.
9. Content capture, PII and redaction
The conventions are explicit: instructions, user messages and model outputs are "considered sensitive and are often large in size", and instrumentations "SHOULD NOT capture them by default". They describe three patterns:
| Pattern | When | Trade-off |
|---|---|---|
| 1. Do not record content (default) | Always the starting point | Safe, but debugging needs a way to reproduce |
2. Record content on span attributes (gen_ai.input.messages etc.) |
Pre-production, or where the telemetry store meets the privacy requirements | Simple; large payloads may exceed backend attribute limits |
| 3. Store content externally, put references on spans | Recommended for production | Separate access control and retention for user data |
Practical rules:
- Redact before export, in the process or the Collector. The Collector's
redactionprocessor can drop attributes not on an allow list (allow_all_keys: false) and mask values that match regular expressions (blocked_values, for example card numbers or emails). Thetransformprocessor can truncate long attribute values. - Treat tool arguments and results as content.
gen_ai.tool.call.argumentsandgen_ai.tool.call.resultare opt-in for the same reason: they often contain customer data. - Separate retention. Keep metadata (tokens, latency, model, finish reason) for months; keep content for days, in a store with stricter access.
- Hash, do not store, identifiers you only need for grouping (user ID, email), unless you need to look them up.
- Never log secrets in prompts. If an API key or password can end up in a prompt, it will end up in a trace.
10. Sampling
LLM traces are large, and the interesting ones are rare. Plain head sampling (keep 1% decided at the start) throws away most of the failures you want.
| Strategy | Keep | How |
|---|---|---|
| Head sampling | A fixed percentage of all traces | SDK sampler, e.g. ParentBased(TraceIdRatioBased(0.05)) |
| Tail sampling | Errors, slow traces, expensive traces, plus a baseline percentage | Collector tail_sampling processor with status, latency and attribute policies |
| Always keep | Traces with negative user feedback, guardrail triggers, length finish reasons |
Tail sampling policy on attributes you set |
| Metrics unsampled | All token, duration and cost metrics | Metrics are aggregated in-process; sampling traces does not affect them |
The last row matters: compute cost and token metrics from every call, not from sampled traces, or your cost numbers will be wrong by the sampling rate.
11. Tools landscape
Facts from each project's own documentation. All of them accept or build on OpenTelemetry to some degree, which is the main reason to instrument with OTel conventions rather than a vendor SDK.
| Tool | What it is | OTel relationship |
|---|---|---|
| OpenTelemetry GenAI instrumentation | Official instrumentation libraries in the OTel Python contrib repo, starting with OpenAI (announced December 2024) | Emits the GenAI semantic conventions |
| Langfuse | Open source LLM engineering platform (MIT-licensed core, self-hostable): tracing, prompt management, evals | Python SDK v3, generally available June 5, 2025, is built on OpenTelemetry; Langfuse accepts OTLP traces |
| Arize Phoenix | Open source AI observability platform from Arize (Elastic License 2.0) for tracing, evaluation and experiments | Built on OpenTelemetry; uses OpenInference conventions |
| OpenInference | Arize's set of conventions and instrumentation plugins for AI apps | "Complementary to OpenTelemetry"; usable with any OTel-compatible backend |
| OpenLLMetry | Traceloop's open source (Apache 2.0) extensions built on OpenTelemetry, with instrumentations for many LLM providers and frameworks | Exports standard OTel data to existing backends; Traceloop.init() to start |
Note that OpenInference and the OTel GenAI conventions use different attribute names for similar concepts. If you mix instrumentations, check what each emits before building dashboards.
12. Checklist
- [ ] Every LLM call is a span with
gen_ai.operation.name,gen_ai.provider.name, request and response model, and token usage. - [ ] Cost is computed per call from a versioned price table and aggregated per request, feature and tenant.
- [ ] TTFT and total duration are tracked per model, with output length taken into account.
- [ ] Content capture is off by default; where it is on, it is redacted and stored with separate access and retention.
- [ ] Tail sampling keeps errors, slow and expensive traces; metrics are unsampled.
Why this matters
- LLM failures are silent at the HTTP level. Tokens, finish reasons and tool spans make them visible.
- Cost is a production metric. Without per-call token accounting, the bill is the first alert.
- Agents need traces, not logs. A run is a tree of model and tool calls; only a trace shows where it looped or stalled.
- Standard names keep you portable. OTel GenAI conventions let you switch backends without re-instrumenting, even while they are still experimental.
- Content is both the best debugging signal and the biggest risk. Decide deliberately where it goes.
Final Thoughts
Start with metadata: one span per model call with the required attributes, token counts and a cost attribute, and tool spans under the same trace. That alone answers most production questions: what is slow, what is expensive, what changed. Add content capture only where you can store it safely, and tail sampling once volume makes it necessary.
The GenAI conventions are still marked as development and will keep moving. Pin versions, read the changelog when you upgrade, and keep your own additions in your own namespace. The shape (spans for calls, tools and agents; histograms for tokens and duration) is already stable enough to build on.
Sources
- OpenTelemetry, Semantic conventions for generative AI systems and the v1.38.0 GenAI docs: Development status,
OTEL_SEMCONV_STABILITY_OPT_INtransition plan. - OpenTelemetry, GenAI client spans, v1.38.0: span names and kinds, attributes and requirement levels, execute tool span, content capture patterns.
- OpenTelemetry, GenAI agent spans, v1.38.0:
create_agent,invoke_agent, agent attributes. - OpenTelemetry, GenAI metrics, v1.38.0: token usage and duration histograms, bucket boundaries, server TTFT and time per output token.
- OpenTelemetry, semantic-conventions releases: v1.37.0 (August 25, 2025) and v1.38.0 (October 29, 2025) GenAI changes.
- Drew Robbins and Liudmila Molkova, OpenTelemetry for Generative AI (OpenTelemetry blog, December 5, 2024): OpenAI instrumentation,
opentelemetry-instrument,OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT. - OpenTelemetry Collector contrib, redaction processor and tail sampling processor.
- Langfuse, Python SDK v3 generally available (June 5, 2025) and OpenTelemetry docs: OTel-based SDK, OTLP endpoint.
- Arize, Phoenix repository: license, built on OpenTelemetry and OpenInference.
- Arize, OpenInference repository: conventions complementary to OpenTelemetry.
- Traceloop, OpenLLMetry repository: Apache 2.0, extensions built on OpenTelemetry,
Traceloop.init().