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:
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:
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:
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:
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.