OpenTelemetry GenAI conventions
You can create OpenTelemetry spans for model, agent and tool calls using the GenAI semantic conventions, and send them to any OTLP backend.
Words in this lessonOpenTelemetry · Semantic conventions · OTLP · Span · Trace · Agent · Tool calling
- OpenTelemetry
- An open standard, not tied to any company, for recording traces, metrics and logs. Like a national postal system with standard parcels and trucks, so any courier and any sorting hub can handle any parcel.
- Semantic conventions
- Agreed names for the fields in traces, so every tool reads them the same way. Like the standard layout of an address label with a PIN code in a fixed place, so any post office can route the parcel.
- OTLP
- The standard format for sending traces, metrics and logs from your app to a tool that stores them. Like the delivery trucks of the postal system: they carry any standard parcel to any sorting hub.
- Span
- One timed step inside a trace, such as a search or a model call. Like one line on a kitchen batch card: "baked at 180 degrees, 28 minutes, checked by Ravi".
- Trace
- A detailed record of everything one request did, step by step, including every model call and its timing. Like a parcel tracking history that lists every depot the parcel passed through and how long it waited at each.
- Agent
- A model running in a loop that decides its own next step, uses tools, checks results and keeps going until a task is done. Like a personal assistant you hand a goal, who makes the calls and checks back, rather than one you direct step by step.
- Tool calling
- When a model asks your code to run a named function with specific inputs, then uses the result. Like a waiter who cannot cook but can send a ticket to the kitchen and bring back what comes out.
On this page
Sneha brought her laptop to Arjun’s desk on Friday with a case that had eaten her morning.
A customer, Farhan, had asked CrumbBot at 1:12 pm where order CR-58213 was. CrumbBot replied, “Your order was delivered at 12:40 pm.” It had not been. The rider was stuck in traffic on Baner Road and delivered at 1:55. Farhan was not happy about being told his cake had arrived while he was standing at an empty door.
Sneha’s half of the story lived in Grafana, the dashboard her backend team uses for every service. She could see the order service receive GET /orders/CR-58213 at 1:12:09 and return out_for_delivery. Arjun’s half lived in Langfuse: the agent called the order_status tool, got a result, and wrote the wrong answer. Two tools, two different trace ids, no link between them. They matched the two halves by timestamp, by hand, which took forty minutes. (The cause, it turned out, was an old prompt example that showed “delivered” as a sample answer. Module 23 will make catching that sort of thing routine.)
“I don’t want a second silo,” Sneha said. “Our services already speak OpenTelemetry. If the bot spoke it too, I’d see one tree: the agent, the model call, the tool, my HTTP request and my database query. One trace id.”
Priya nodded. “And Langfuse speaks it as well. So let’s teach the bot the common language.”
Important
The idea in one sentence: OpenTelemetry is a vendor-neutral standard for recording and shipping traces, and its GenAI semantic conventions are the agreed names for AI spans and attributes (chat claude-opus-5-5, gen_ai.usage.input_tokens), so any tool, from Grafana to Langfuse, can read your AI traces without custom glue.
Arjun’s first attempt: OpenTelemetry with his own names
Arjun had used OpenTelemetry once, in a tutorial. He wrapped CrumbBot’s model call in a span and gave the attributes the first names that came to mind.
with tracer.start_as_current_span("llm_call") as span:
response = client.messages.create(...)
span.set_attribute("model", "opus")
span.set_attribute("tokens_in", response.usage.input_tokens)
span.set_attribute("tokens_out", response.usage.output_tokens)
The spans arrived in both Grafana and Langfuse. Then the problems started.
Langfuse showed the span as a generic span with three mystery attributes: no model, no token columns, no cost. It had no way to know that tokens_in meant input tokens. Sneha’s Grafana dashboard had a panel for “AI tokens per hour” that a colleague had built from an open-source template. It queried an attribute called gen_ai.usage.input_tokens and showed a flat line at zero. And when a second developer added tracing to the handbook assistant from Module 21, she called the same thing prompt_tokens.
“Three names for one number,” Priya said. “Every tool needs a translation table for every team. That’s the problem semantic conventions solve.”
The address label
Priya wrote an address on the whiteboard, Indian-style, ending with Pune 411045.
“Why can a post office in Guwahati route a parcel to Baner without phoning anyone?”
“The PIN code,” Arjun said. “Six digits, always in the same place.”
“Right. India Post doesn’t care what’s in the parcel or which courier carried it. It cares that the label follows the standard: name, address, PIN code. Every sorting hub in the country can read it.”
She drew two layers under the label.
“OpenTelemetry is the postal system: the rules for what a parcel is, the trucks, the sorting hubs. The semantic conventions are the label format: the agreed names, so that a field called gen_ai.usage.input_tokens means the same thing in every tool.”
“Where does it break?”
“Two places. India Post enforces its format and it hasn’t changed in decades. The GenAI conventions are an agreement still being written. Officially they’re in development status, and names have already changed. An older name, gen_ai.system, was renamed to gen_ai.provider.name. So you follow the label format, and you expect to update it now and then.”
The pieces of OpenTelemetry
OpenTelemetry, often shortened to OTel, is an open-source, vendor-neutral standard for telemetry: traces, metrics and logs. It is run by the Cloud Native Computing Foundation (CNCF), the same non-profit home as Kubernetes, and supported by almost every observability vendor. Its pieces:
- API: the functions your code calls, like
start_as_current_span. Tiny and stable. - SDK: the engine behind the API. It assigns trace and span ids, times spans, and hands finished spans to processors.
- Instrumentation: code that creates spans for you, for a library like an HTTP client, a database driver or an LLM SDK.
- Exporter: the piece that sends finished spans somewhere: to your console, or over the network.
- OTLP, the OpenTelemetry Protocol: the wire format for sending telemetry. It is the truck. Any backend that “accepts OTLP” can receive OTel data.
- Collector (optional): a separate program that receives spans, can filter, mask or sample them, and forwards them to one or more backends.
- Backend: where spans are stored and shown: Grafana Tempo, Jaeger, Honeycomb, Datadog, Langfuse.
flowchart LR
A["CrumbBot (OTel API and SDK)"] -->|"OTLP"| C["OTel Collector"]
B["Order service (OTel)"] -->|"OTLP"| C
C --> T["Grafana Tempo (Sneha)"]
C --> L["Langfuse (Arjun)"]
This was Crumb’s plan in one picture. Both services send OTLP to one Collector. The Collector sends every span to Grafana Tempo for Sneha, and the AI spans to Langfuse for Arjun. One standard, two views, no silo.
One model call, every label filled in
Here is the brownie answer from Lesson 1, the generate_answer call, written the way the GenAI conventions describe it. Every value is real.
Span name: chat claude-opus-5-5. The convention is {operation} {target}: what kind of AI work, then which model, tool or agent. Other examples: embeddings text-embedding-3-small, execute_tool order_status, invoke_agent support_agent.
Span kind: CLIENT, because CrumbBot is calling out to a remote service.
Duration: 2.651 s.
| Attribute | Value | Meaning |
|---|---|---|
gen_ai.operation.name |
chat |
The kind of AI work |
gen_ai.provider.name |
anthropic |
Who served it |
gen_ai.request.model |
claude-opus-5-5 |
The model you asked for |
gen_ai.request.max_tokens |
500 |
The output cap you set |
gen_ai.response.model |
claude-opus-5-5 |
The model that actually answered |
gen_ai.response.id |
msg_01HxQ7rV3kP9 |
The provider’s id for this response |
gen_ai.response.finish_reasons |
["end_turn"] |
Why it stopped (Claude’s stop_reason) |
gen_ai.usage.input_tokens |
1912 |
Tokens read |
gen_ai.usage.output_tokens |
96 |
Tokens written |
gen_ai.conversation.id |
sess-7f3a |
Which conversation (session) this belongs to |
server.address |
api.anthropic.com |
Where the request went (a general OTel attribute) |
Three things are missing on purpose.
No temperature. gen_ai.request.temperature exists in the conventions, but CrumbBot does not send one: claude-opus-5-5 does not take a temperature setting. Record what you sent, not every possible field.
No cost. At the time of writing, the conventions don’t define a cost attribute. Cost is computed by the backend from the token counts and a price table. For this call: 1,912 × 20 / 1M = $0.00957, about ₹0.84 at roughly ₹88 to the dollar. Langfuse does that maths for you.
No message content. The prompt and the answer are not in the table. The conventions do define attributes for them, gen_ai.input.messages, gen_ai.output.messages and gen_ai.system_instructions, but capturing them is opt-in: off unless you turn it on.
Note
Pause and think. For debugging, the message content is the most useful thing in the whole span. Why would the standard make it opt-in instead of on by default?
Show the answer
Because it is the most sensitive thing in the span, and it is big. The content is where Riya’s peanut allergy and Farhan’s address live. Many teams send OTel spans to shared backends that dozens of engineers can read, often keep for months, and sometimes run at another company. A default that quietly copied every customer message there would leak personal data by accident. Size matters too: a 1,912-token prompt is about 8 KB of text in every span. Making content opt-in forces a conscious decision: turn it on where access and retention are controlled (Crumb’s self-hosted Langfuse), and keep it off for the shared Grafana.
Building it: one span on your console
Time to build the span for real. Start with the console exporter, which prints spans to your terminal, so you can see exactly what OTel produces before sending it anywhere.
# uv add opentelemetry-sdk anthropic python-dotenv
import anthropic
from dotenv import load_dotenv
from opentelemetry import trace
from opentelemetry.sdk.resources import Resource
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import ConsoleSpanExporter, SimpleSpanProcessor
load_dotenv()
# 1. Set up the SDK once, at startup.
provider = TracerProvider(resource=Resource.create({"service.name": "crumb-support"}))
provider.add_span_processor(SimpleSpanProcessor(ConsoleSpanExporter()))
trace.set_tracer_provider(provider)
tracer = trace.get_tracer("crumb.support")
client = anthropic.Anthropic()
MODEL = "claude-opus-5-5"
# 2. Wrap one model call in a span named "{operation} {model}".
with tracer.start_as_current_span(f"chat {MODEL}", kind=trace.SpanKind.CLIENT) as span:
span.set_attribute("gen_ai.operation.name", "chat")
span.set_attribute("gen_ai.provider.name", "anthropic")
span.set_attribute("gen_ai.request.model", MODEL)
span.set_attribute("gen_ai.request.max_tokens", 500)
response = client.messages.create(
model=MODEL,
max_tokens=500,
messages=[{"role": "user", "content": "Do you have eggless cakes?"}],
)
# 3. Record what came back.
span.set_attribute("gen_ai.response.model", response.model)
span.set_attribute("gen_ai.response.id", response.id)
span.set_attribute("gen_ai.response.finish_reasons", [response.stop_reason])
span.set_attribute("gen_ai.usage.input_tokens", response.usage.input_tokens)
span.set_attribute("gen_ai.usage.output_tokens", response.usage.output_tokens)
print(next(b.text for b in response.content if b.type == "text"))
What is new here:
- A
TracerProvideris the SDK’s central object. TheResourcedescribes who is producing spans:service.nameis how Grafana and Langfuse will label everything from CrumbBot. - A span processor decides when finished spans are handed to an exporter.
SimpleSpanProcessorhands over each span immediately, which is good for learning and slow for production. get_tracer("crumb.support")gives you a tracer named after your code module.start_as_current_spanopens a span and makes it the current one, so any span opened inside becomes its child. This is the same trick as the toy tracer in Lesson 1, with a context variable underneath. If the block raises an exception, OTel records it and marks the span as an error.
Run it. The answer prints, and the exporter prints the span. Abridged, with your ids and times differing:
{
"name": "chat claude-opus-5-5",
"context": {
"trace_id": "0x4bf92f3577b34da6a3ce929d0e0e4736",
"span_id": "0x00f067aa0ba902b7"
},
"kind": "SpanKind.CLIENT",
"parent_id": null,
"start_time": "2026-10-30T09:14:02.118473Z",
"end_time": "2026-10-30T09:14:04.512906Z",
"status": { "status_code": "UNSET" },
"attributes": {
"gen_ai.operation.name": "chat",
"gen_ai.provider.name": "anthropic",
"gen_ai.request.model": "claude-opus-5-5",
"gen_ai.request.max_tokens": 500,
"gen_ai.response.model": "claude-opus-5-5",
"gen_ai.response.id": "msg_01Fq2Zt8YbWc",
"gen_ai.response.finish_reasons": ["end_turn"],
"gen_ai.usage.input_tokens": 14,
"gen_ai.usage.output_tokens": 87
},
"resource": { "attributes": { "service.name": "crumb-support" } }
}
status_code: UNSET is normal. It means “nothing went wrong that we know of”; OTel only sets ERROR when it sees one. parent_id: null means this is a root span, the top of its own trace.
Note
Pause and think. Arjun suggested naming the span chat claude-opus-5-5 cust-4412, so he could search spans by customer. Sneha said no. Why does the convention put the model in the span name but not the customer id?
Show the answer
Span names should have low cardinality: a small, fixed set of possible values. Tools group, count and chart spans by name, so “all chat claude-opus-5-5 spans” gives you a useful latency chart. Crumb uses a handful of models, so names stay few. Put a customer id in the name and you get thousands of names, one per customer, and every chart shatters into thousands of one-point lines. Some backends also charge or slow down by the number of distinct names. Details that vary per request, like customer ids or session ids, belong in attributes, where you can still filter on them.
Nesting: agent, model, tool
A real CrumbBot request is a little agent (Module 13): the model may ask for a tool, your code runs it, and the model answers with the result. The conventions have a span for each part:
invoke_agent support_agentfor the agent’s whole run, withgen_ai.agent.name;chat claude-opus-5-5for each model call inside it;execute_tool order_statusfor each tool call, withgen_ai.tool.nameandgen_ai.tool.call.id(the id Claude gave that tool request, so you can match the request to its result).
Here is Farhan’s question as a traced agent. It reuses the setup lines from the previous script.
TOOLS = [{
"name": "order_status",
"description": "Look up the delivery status of a Crumb order.",
"input_schema": {"type": "object",
"properties": {"order_id": {"type": "string"}},
"required": ["order_id"]},
}]
def traced_chat(messages: list) -> anthropic.types.Message:
with tracer.start_as_current_span(f"chat {MODEL}", kind=trace.SpanKind.CLIENT) as span:
span.set_attribute("gen_ai.operation.name", "chat")
span.set_attribute("gen_ai.provider.name", "anthropic")
span.set_attribute("gen_ai.request.model", MODEL)
response = client.messages.create(model=MODEL, max_tokens=500,
tools=TOOLS, messages=messages)
span.set_attribute("gen_ai.response.finish_reasons", [response.stop_reason])
span.set_attribute("gen_ai.usage.input_tokens", response.usage.input_tokens)
span.set_attribute("gen_ai.usage.output_tokens", response.usage.output_tokens)
return response
def order_status(order_id: str, call_id: str) -> str:
with tracer.start_as_current_span("execute_tool order_status") as span:
span.set_attribute("gen_ai.operation.name", "execute_tool")
span.set_attribute("gen_ai.tool.name", "order_status")
span.set_attribute("gen_ai.tool.call.id", call_id)
return f"{order_id}: out_for_delivery, rider on Baner Road, ETA 13:55" # fake lookup
def run_agent(question: str, conversation_id: str) -> str:
with tracer.start_as_current_span("invoke_agent support_agent") as span:
span.set_attribute("gen_ai.operation.name", "invoke_agent")
span.set_attribute("gen_ai.agent.name", "support_agent")
span.set_attribute("gen_ai.conversation.id", conversation_id)
messages = [{"role": "user", "content": question}]
for _ in range(5): # never loop forever
response = traced_chat(messages)
if response.stop_reason != "tool_use":
return next(b.text for b in response.content if b.type == "text")
messages.append({"role": "assistant", "content": response.content})
results = [{"type": "tool_result", "tool_use_id": b.id,
"content": order_status(b.input["order_id"], b.id)}
for b in response.content if b.type == "tool_use"]
messages.append({"role": "user", "content": results})
return "Sorry, I couldn't finish that. A team member will follow up."
print(run_agent("Where is my order CR-58213?", "sess-91c2"))
The console now prints four spans. Here they are reduced to name, parent and the key numbers, in the order they started:
invoke_agent support_agent span 0x1a.. parent None 2.94 s
chat claude-opus-5-5 span 0x2b.. parent 0x1a.. 1.12 s finish=tool_use in=612 out=58
execute_tool order_status span 0x3c.. parent 0x1a.. 0.01 s call_id=toolu_01Kp...
chat claude-opus-5-5 span 0x4d.. parent 0x1a.. 1.79 s finish=end_turn in=704 out=41
All four share one trace id. The two model calls and the tool call are siblings under the agent span, in the order they happened. Add up the tokens and you get the cost of the whole conversation turn: (612 + 704) × 20 / 1M = 0.00198 = $0.00724, about ₹0.64.
The payoff for Sneha comes next. When order_status makes a real HTTP call to the order service, OpenTelemetry’s HTTP instrumentation adds a traceparent header to the request: a short string carrying the trace id and the current span id (a W3C standard called Trace Context). The order service reads it and makes its own spans children of execute_tool order_status. Her GET /orders/CR-58213 and its database query appear in the same tree, under the tool call, with the same trace id. No more matching by timestamp.
Sending spans somewhere real
For production, swap two pieces: a batch processor instead of the simple one, and an OTLP exporter instead of the console.
# uv add opentelemetry-exporter-otlp
from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter
from opentelemetry.sdk.trace.export import BatchSpanProcessor
provider.add_span_processor(BatchSpanProcessor(
OTLPSpanExporter(endpoint="http://otel-collector:4318/v1/traces") # Crumb's Collector
))
BatchSpanProcessor collects spans and sends them in the background in groups, so your chat replies never wait on the network. Port 4318 is the usual OTLP-over-HTTP port. Langfuse can also receive OTLP directly at its own endpoint with an authentication header; its docs list the exact path.
Library APIs change quickly. If a name has moved, check the docs linked below.
You may not need to write these spans by hand at all. At the time of writing, OpenLLMetry (Lesson 2), OpenTelemetry’s own instrumentation packages for several provider SDKs, and Anthropic’s SDK (in beta) can emit GenAI spans automatically. Writing one by hand first means you can read what they produce, and spot when they get it wrong.
Where it breaks
The names move. The conventions are still in development. The rename of gen_ai.system to gen_ai.provider.name broke dashboards that queried the old name. Some instrumentation libraries emit the old names until you opt in to the newest ones with a setting; their docs say how. Pin your library versions and check your dashboards after upgrades.
Two instrumentations, one call. Turn on auto-instrumentation and keep your hand-written chat span, and every model call appears twice, with tokens counted twice. Choose one per call.
Content in the wrong place. If you turn on content capture in the app, it goes to every backend the Collector forwards to, including the shared Grafana that keeps data for 90 days. Crumb’s answer: keep content capture on, and have the Collector strip the gen_ai.input.messages and gen_ai.output.messages attributes before forwarding to Tempo, while Langfuse receives them in full.
Not everything fits the standard yet. Retrieval details like chunk ids and similarity scores have no settled GenAI names at the time of writing. Use your own clearly prefixed attributes, like crumb.retrieval.chunk_ids, rather than guessing at official-looking ones.
Try it yourself
Extend the agent script so a failed tool call is visible.
- Make
order_statusraiseConnectionError("order service down")when the order id ends in13. - Inside
run_agent, catch the error, set the attributeerror.typeto"ConnectionError"on the agent span, and send the model the tool result text"Order service unavailable"instead. - Run
run_agent("Where is my order CR-58213?", "sess-test").
Expected result. The console shows an execute_tool order_status span with "status_code": "ERROR" and an exception event, because start_as_current_span records exceptions that leave the block. The agent span carries error.type: ConnectionError. The second chat span shows the model replying that it can’t check the order right now. One trace tells the whole story: which tool failed, and how the agent coped.
Show the answer
def order_status(order_id: str, call_id: str) -> str:
with tracer.start_as_current_span("execute_tool order_status") as span:
span.set_attribute("gen_ai.operation.name", "execute_tool")
span.set_attribute("gen_ai.tool.name", "order_status")
span.set_attribute("gen_ai.tool.call.id", call_id)
if order_id.endswith("13"):
raise ConnectionError("order service down")
return f"{order_id}: out_for_delivery"
# In run_agent, replace the results list with:
results = []
for b in response.content:
if b.type == "tool_use":
try:
content = order_status(b.input["order_id"], b.id)
except ConnectionError:
span.set_attribute("error.type", "ConnectionError")
content = "Order service unavailable"
results.append({"type": "tool_result", "tool_use_id": b.id, "content": content})
span inside run_agent is the agent span from its own with line.
Common mistakes
- Inventing attribute names when a convention exists.
tokens_inworks only in your head. Usegen_ai.usage.input_tokensand every tool understands it. - Putting per-request values in span names. Customer ids, order ids and session ids go in attributes. Span names stay few.
- Using
SimpleSpanProcessorin production. It exports each span as it ends, on your request’s time. UseBatchSpanProcessor. - Turning on content capture everywhere. Decide per backend. Strip content before it reaches shared, long-retention stores.
- Assuming the conventions are final. They are in development. Expect renames, pin versions, and re-check dashboards after upgrades.
Recap
- OpenTelemetry is the vendor-neutral standard for traces, metrics and logs: API and SDK in your app, instrumentation, exporters, the OTLP wire format, an optional Collector, and any backend.
- Semantic conventions are agreed names. GenAI spans are named
{operation} {target}, likechat claude-opus-5-5, and carrygen_ai.*attributes for model, provider, tokens, finish reasons, conversation, tool and agent. - Message content is opt-in because it is sensitive and large. Cost is computed by the backend from tokens.
- Nested spans (
invoke_agentoverchatandexecute_tool) and trace context headers let AI spans and ordinary service spans share one tree. - The GenAI conventions are still evolving: names have changed and will change again.
On Monday, Sneha opened Grafana and searched for Farhan’s next order. One trace: invoke_agent support_agent, two chat claude-opus-5-5 spans, execute_tool order_status, and underneath it her own GET /orders and the SQL query, 31 ms. Arjun opened Langfuse and saw the same trace, with the prompts and a cost of ₹0.64.
Meera asked what had changed. “The bot now writes its notes in the same language as the rest of our systems,” Arjun said. “Like using PIN codes instead of describing the route to each house. Any tool can read them, so Sneha and I look at the same request instead of two halves.”
“Good. And the brownie?”
“That’s next. Now that every request records itself properly, we can replay Nandini’s question and read exactly what the bot read.”
Go deeper
- OpenTelemetry documentation (concepts, Python getting started, the Collector): https://opentelemetry.io/docs/
- OpenTelemetry semantic conventions for generative AI: https://opentelemetry.io/docs/specs/semconv/gen-ai/
- Langfuse documentation, including its OpenTelemetry integration: https://langfuse.com/docs
- OpenLLMetry, auto-instrumentation that emits GenAI spans: https://github.com/traceloop/openllmetry