Skip to content
ContextSystems
Field notes

Tracing an LLM Agent End-to-End: OpenTelemetry GenAI Semantic Conventions in Practice

A field note on naming, attributes, and traces for LLM agents using OpenTelemetry GenAI semantic conventions.

Mellisa Waltzer8 min read

An agent run takes nine seconds and costs more than it should. A user asked one question. Somewhere inside, the agent called a model, called a tool, then called the model again. You have the trace but no way to tell which span is the slow one, because every span is named after the function that emitted it and every token count lives in a log line nobody wrote.

That is the problem the OpenTelemetry GenAI semantic conventions solve. Not by adding a dashboard. By agreeing on names. If your agent spans carry the same attribute names that everyone else’s spans carry, then your traces and your dashboards stop being tied to one vendor. You can read another team’s trace. You can swap your instrumentation library and keep your queries. The names are the contract. This piece hands you that contract from the primary source. It shows a real library that already emits the schema and stays honest about how finished it is.

What the conventions are, and where they live now

First, calibrate your risk. The GenAI semantic conventions carry OpenTelemetry’s Development maturity level, which sits below Stable [F01]. You can build on them. You should also pin your instrumentation version and expect some churn before they freeze.

Second, know where to read them. The GenAI conventions recently moved out of the main opentelemetry.io semantic-conventions pages into a dedicated repository. The old pages now say so plainly: “GenAI semantic conventions have moved to the OpenTelemetry GenAI semantic conventions repository. This page has moved and is no longer maintained in this repository” [F02]. The live text lives in open-telemetry/semantic-conventions-genai. If you open an opentelemetry.io/docs/specs/semconv/gen-ai URL and see a redirect banner, that is why. Read the repo.

Span naming

A GenAI span name should be the operation name followed by the request model: “Span name SHOULD be {gen_ai.operation.name} {gen_ai.request.model}” [F03]. That single rule does more work than any other in the spec. A chat call to gpt-4o becomes a span named chat gpt-4o. You do not have to guess what a span is from the function that emitted it. You read the model and the operation off the name.

The operation name comes from a fixed set. For direct model calls you get chat, described as a “Chat completion operation such as OpenAI Chat API,” and text_completion for the legacy completions style [F14]. There is also embeddings for vector calls and generate_content for multimodal generation such as Gemini [F33]. For agents, two more matter most: invoke_agent, described as “Invoke GenAI agent,” and execute_tool, described as “Execute a tool.” A third value, create_agent (“Create GenAI agent”), covers agent setup [F15]. That short list is enough to name every span an agent produces.

Core attributes: models, tokens, finish reasons

Names on the span are only half of it. The attributes are where the useful data sits. A few of them are non-negotiable.

gen_ai.provider.name is “The Generative AI provider as identified by the client or server instrumentation” [F05]. Its enum lists concrete providers you actually use, including openai and anthropic, with cloud entries such as aws.bedrock and gcp.vertex_ai in the same list [F16]. One note from reading the registry directly: gen_ai.system, which older guides still mention, no longer appears in the current attribute registry. The attribute to use today is gen_ai.provider.name. That is what the registry now contains rather than a formal deprecation, so treat the rename as a current-state observation [F17].

For cost and behavior, four attributes do most of the work:

  • gen_ai.request.model is “The name of the GenAI model a request is being made to,” and gen_ai.response.model is “The name of the model that generated the response” [F06][F07]. These differ more often than you would think when a provider routes you to a dated snapshot.
  • gen_ai.usage.input_tokens is “The number of tokens used in the GenAI input (prompt)” and gen_ai.usage.output_tokens is “The number of tokens used in the GenAI response (completion)” [F08][F09]. Those two attributes are your bill, per span.
  • gen_ai.response.finish_reasons is an “Array of reasons the model stopped generating tokens, corresponding to each generation received” [F10]. This is how you catch truncation at length before a user reports a cut-off answer.
  • gen_ai.response.id is “The unique identifier for the completion” [F11], which lets you tie a span back to the provider’s own logs.

Request settings such as gen_ai.request.temperature, defined as “The temperature setting for the GenAI request,” and gen_ai.request.max_tokens, “The maximum number of tokens the model generates for a request,” ride along too [F12][F13]. When output quality drifts, the first thing to check is whether a config change moved temperature on a specific span.

An agent run is a span tree

This is what the title promises. End-to-end means one trace where the whole agent loop is legible.

The parent is an invoke_agent span. Its name should be invoke_agent plus the agent name when that name is available, and just invoke_agent when it is not [F28]. Each tool call the agent makes becomes an execute_tool span, named execute_tool followed by the tool name [F29]. The convention is explicit about the shape of the tree: “The LLM call that generates the plan SHOULD be a child of the plan span, and the tool or task spans produced from the plan are typically sibling operations under the same invoke_agent span” [F30]. So a single agent turn reads top to bottom as one parent with model spans and tool spans nested under it, in the order they happened.

The agent and tool spans carry their own attributes. gen_ai.agent.name is “The human-readable name of the invoked GenAI agent,” gen_ai.agent.id is “The stable unique identifier of the invoked GenAI agent,” and gen_ai.tool.name is “Name of the tool utilized by the agent.” A tool call also gets gen_ai.tool.call.id, “The tool call identifier,” plus gen_ai.tool.type, which records the tool kind with values such as function or datastore [F31]. With those in place, the nine-second trace stops being a mystery. You open the invoke_agent span and scan the children. The slow one is right there with its model name and its token count attached.

Instrumentation with opentelemetry-instrumentation-openai-v2

None of this requires hand-rolled spans. The OpenTelemetry Python contrib project provides opentelemetry-instrumentation-openai-v2, which instruments the OpenAI SDK for you. One call wires it up. The example is source-derived from the package README, not from a live run [F32]:

from opentelemetry.instrumentation.openai_v2 import OpenAIInstrumentor

OpenAIInstrumentor().instrument()

client = OpenAI()
response = client.chat.completions.create(
    model="gpt-4o-mini",
    messages=[
        {"role": "user", "content": "Write a short poem on open telemetry."},
    ],
)

Per the package README, that single instrument() call turns the chat completion into a span named chat gpt-4o-mini, carrying the operation name and the token counts described above, along with the finish reason. Because it is a contrib package under the Development conventions, pin the version you test against. The schema is not theoretical. You can emit it today.

Content capture is off by default

Here is the part that surprises people. Prompt and completion text is not captured by default. The convention says that model instructions and model outputs are “sensitive and are often large in size,” and that “OpenTelemetry instrumentations SHOULD NOT capture them by default, but SHOULD provide an option for users to opt in” [F18][F19]. The Python instrumentation follows that rule exactly: “Message content such as the contents of the prompt, completion, function arguments and return values are not captured by default” [F23]. You turn it on with the OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT environment variable [F24].

When you do opt in, the content flows through attributes of its own. gen_ai.input.messages is “The chat history provided to the model as an input,” gen_ai.output.messages holds “Messages returned by the model where each message represents a specific model response,” and gen_ai.system_instructions carries “The system message or instructions provided to the GenAI model” [F20]. There is also an opt-in event that “could be used to store input and output details independently from traces,” which lets you route raw content to a separate store with tighter access [F21]. Take the warning seriously: that content “is likely to contain sensitive information including user/PII data” [F22]. A sound default is content off in production, on in a staging trace when actively debugging a prompt.

Metrics for the dashboard

Traces answer “what happened in this run.” Metrics answer “what is happening across all runs.” The conventions define two that belong on every GenAI dashboard. gen_ai.client.token.usage is a Histogram in unit {token}, described as “Number of input and output tokens used” [F25], and it splits by a gen_ai.token.type attribute whose values are input and output [F27], so one instrument gives you both sides of the bill. gen_ai.client.operation.duration is a Histogram in seconds, described as “GenAI operation duration” [F26]. Alert on the p95 of duration and the sum of token usage per model, and you will see cost and latency regressions before your users do.

Adopting the conventions

Adopt the names. That is the whole recommendation. Once your agent spans are named chat gpt-4o and your token counts live on gen_ai.usage.input_tokens, your observability stops belonging to a vendor and starts belonging to you. Your traces nest tool calls under invoke_agent, and any engineer who opens them can follow the run. The conventions are still Development, so pin your versions and keep content capture off unless you have a concrete reason to turn it on. The nine-second agent is still slow, but now you know exactly which span to blame, and so will the next engineer who reads the trace.

Written by Context Systems. This piece was researched from the project’s public docs, release notes, and source. The way we write everything.

Your next piece can start with the product the same way.

See pricing