Engineering
2026-05-2210 min read

OpenTelemetry GenAI Semantic Conventions for LangChain — what to instrument and what to skip

The OTel GenAI conventions name the attributes a GenAI span carries. Most LangChain callbacks emit useful spans. A few are just noise. Here's the field-tested recipe we landed on after instrumenting a few too many of our own agents.

Share:

Why a recipe and not the whole spec

The OpenTelemetry GenAI Semantic Conventions are good. They're public, they're evolving, and any AI observability stack you'd pick today should be reading them natively. If you want the canonical reference, the OTel maintainers keep it at `opentelemetry.io/docs/specs/semconv/gen-ai/` and it's worth a read.

What we want to talk about here is something narrower: when you actually plug an OTel exporter into a LangChain agent, the callback surface is huge. Some of the callbacks produce spans you absolutely want. A few produce spans that look useful, fire a thousand times a second on a busy agent, and you regret later. Here's the recipe we use for our own agents and the design partners we work with.

The five spans you actually want

These five carry almost all of the debugging value.

1. `llm.start` → `llm.end` — the model call. Attribute the standard GenAI fields:

  • `gen_ai.system` — the provider (openai, anthropic, bedrock, gemini, ...)
  • `gen_ai.request.model` — the model identifier you requested
  • `gen_ai.response.model` — what the provider actually used (sometimes different on auto-fallback)
  • `gen_ai.usage.input_tokens` / `gen_ai.usage.output_tokens` — token counts
  • `gen_ai.operation.name` — `chat` for chat completions, `completion` for legacy, `embedding` for vectorisation
  • That's the canonical model call. It's the most-queried span in any agent observability tool. Get this one right and 80% of your downstream queries work.

    2. `tool.start` → `tool.end` — the tool invocation. This is what LLM-only observability misses entirely. Attribute:

  • `gen_ai.tool.name` — the canonical name
  • `gen_ai.tool.call.arguments` — the args the model passed
  • `gen_ai.tool.call.id` — the call id, so you can correlate with the tool result the model saw next
  • on `tool.end`: the return value (truncated if huge) and any exception class
  • on exception: a `tool.error` event with the stack trace
  • You will reference this span every time the agent drifts. "What did the tool return? Was it the empty string? Was it a 4xx? Did the model see the error?" The answers live here.

    3. `chain.start` → `chain.end` — the runnable boundary. Every LCEL `.pipe()` is a chain. Every `AgentExecutor.invoke` is a chain. You want these because they're the structural skeleton of the trace — without them, the LLM and tool spans float in a flat sea.

    Don't over-attribute the chain. Just the inputs hash and outputs hash, plus the runnable name. The detail lives on the children.

    4. `agent.action` — the decision step. When the agent decides which tool to call, that's an action. Attribute the chosen tool name, the reasoning trace if available, and the call args. This is the most useful single span when you're trying to explain agent behaviour to a non-engineer — "the agent decided to call lookup_account_tier because the user asked about billing."

    5. `agent.finish` — the terminal decision. When the agent decides to stop and return a result. The attribute that matters most: how many iterations did it take?

    The three spans you can usually skip

    `text.start` / `text.end`. The legacy completion API. If your LangChain agent is still using `OpenAI()` instead of `ChatOpenAI()`, the callbacks fire `text.start` instead of `llm.start`. Map both to the same canonical `gen_ai` attributes — don't emit two separate span types. Future you, querying for "all model calls," will thank present you for collapsing the two.

    `retriever.start` / `retriever.end`. Useful if your agent does retrieval, but mostly noisy if it's a fast in-memory vector store. Sample these aggressively, or emit them only when the retrieval latency exceeds some threshold.

    `llm.new_token`. Fires once per token in a streaming response. Spans are too expensive a primitive for individual tokens. If you want per-token data, emit it as events on the parent `llm.start` span, not as separate spans.

    The recipe in code

    ```python

    from trefur_observe import TrefurObserve, patch_langchain

    from langchain.agents import AgentExecutor, create_openai_tools_agent

    from langchain_openai import ChatOpenAI

    from langchain_core.tools import tool

    # TrefurObserve.init() + patch_langchain(obs) auto-instrument LangChain with

    # the recipe above: llm/tool/chain spans on by default, retriever sampled,

    # new_token as events.

    obs = TrefurObserve.init(api_key="trf_obs_...")

    patch_langchain(obs)

    @tool

    def lookup_account(account_id: str) -> dict:

    return {"id": account_id, "tier": "enterprise"}

    llm = ChatOpenAI(model="gpt-4o")

    agent = create_openai_tools_agent(llm, [lookup_account], prompt=...)

    executor = AgentExecutor(agent=agent, tools=[lookup_account])

    executor.invoke({"input": "What tier is account A-42?"})

    ```

    If you want to do the wiring yourself rather than auto-instrument, the equivalent recipe with raw OTel + LangChain callbacks is a couple of hundred lines and worth writing once to understand what's happening.

    What this gets you

    Once these five spans are flowing, queries that previously required custom logging just work:

  • "Show me every agent run that called lookup_account_tier and then got a non-2xx response" — filter on tool name + response status.
  • "Which model picks the wrong tool most often?" — group agent.action by gen_ai.request.model.
  • "What's our p95 latency for the support agent this week?" — query chain.end for the AgentExecutor.invoke span, filter by agent name.
  • These are the queries you actually want to run when an agent misbehaves. They land at fraction-of-a-second latency on any OTel-native backend.

    What's still missing from the spec

    A few things the GenAI conventions don't (yet) name well:

  • Sub-agent spawning. Multi-agent systems (CrewAI, AutoGen, LangGraph nested agents) need a way to express "this span is a child agent run, not just a child span." The convention is still settling. We use `gen_ai.agent.id` and `gen_ai.agent.parent.id` and look forward to whatever the committee lands on.
  • Retries with state. When a tool fails and the agent retries with different args, the linkage between attempt 1 and attempt 2 isn't standardised. We attach a `gen_ai.retry.of` attribute pointing to the failed call's span id.
  • Cost. `gen_ai.usage.*` covers tokens, not dollars. We compute cost at ingest time on our side; for now the spec doesn't name a canonical `gen_ai.cost.usd` attribute. Probably coming.
  • If you're starting a new agent observability project, pick a backend that's OTel-native and watch the GenAI working group output. The portability is real and it compounds.

    Try it

    If you want a working LangChain agent with the recipe pre-wired, we've put a sample at [github.com/trefur-ai/trefur-examples](https://github.com/trefur-ai/trefur-examples) — one example per framework, runnable in under five minutes. Free tier, full feature access.

    Ready to put this into practice?

    Start tracing your AI agents in 5 minutes with Trefur Observe.