The Trace Was Correct. The Story Wasn't.
What happened when LangChain knew the agent, LiteLLM knew the model, and Langfuse showed both as separate stories.
I added LiteLLM to CairnOps for a boring reason: I didn’t want six agents to depend on one model provider.
The routing part was easy. The observability part was not.
Before LiteLLM, Langfuse could show me a clean path through the LangGraph workflow. I could see parse_input, get_route, assess_risk, and the rest of the pipeline. Once LiteLLM sat between LangChain and Groq, the model name in those traces became cairnops-llm.
That name was technically correct. It was also useless for the question I cared about.
Was the request handled by the 70B model? Did it fall back to the smaller model? How many tokens did the real provider use?
LiteLLM had those answers. LangChain had the agent context. They just weren’t in the same place.
Two traces for one request
After enabling LiteLLM’s OpenTelemetry-based Langfuse callback, I started seeing litellm_request observations with the real provider, model, token count, latency, and cost.
Great—except each of those requests appeared as its own trace.
So the dashboard was effectively telling me:
LangChain: InputParser called a model
LiteLLM: Groq 70B answered a request
Both statements were true. There was no link between them.
My first instinct was to pass the trace ID manually in request metadata. I tried extra_body, then model_kwargs, then custom headers. One attempt did nothing. Another produced warnings. One broke the agent flow badly enough that I rolled it back immediately.
The useful clue was that the trace ID itself was never the real problem. I was trying to invent my own propagation mechanism while both sides already understood OpenTelemetry.
Let the context cross the HTTP boundary
LangChain already had an active OTel context. LiteLLM was running behind an HTTP boundary. The missing piece was propagating that context with the standard W3C headers.
The relevant code ended up being much smaller than most of my failed attempts:
from opentelemetry.propagate import inject
headers = {}
inject(headers)
return ChatOpenAI(
...,
default_headers=headers,
)
Once traceparent crossed the proxy boundary, LiteLLM stopped opening unrelated root traces. Its observations landed under the active workflow trace.
That fixed correlation, but not duplication.
I now had this:
parse_input
├── ChatOpenAI
└── litellm_request
Same call. Same timing. Same tokens. Two generations.
Decide who owns what
I could have disabled the LangChain callback entirely, but then I would lose the part I actually wanted from it: agent, chain, tool, and retriever structure.
Disabling LiteLLM telemetry would have done the opposite. I would keep the workflow and lose the real model/provider/cost data.
So I made the ownership explicit.
LangChain owns workflow telemetry. LiteLLM owns model telemetry.
I kept the Langfuse handler for the graph structure and filtered only the LLM events:
class StructureOnlyLangfuseHandler(CallbackHandler):
@property
def ignore_llm(self) -> bool:
return True
@property
def ignore_chat_model(self) -> bool:
return True
The final trace looked like the system I was actually running:
expedition_pipeline
├── parse_input
│ └── litellm_request
├── get_route
│ └── litellm_request
└── assess_risk
└── litellm_request
Now one screen answers both questions I need during debugging: which agent made the call, and which model actually answered it.
The part that took me longest wasn’t OpenTelemetry. It was realizing that two libraries reporting to the same observability backend do not automatically share a story.
Correlation solved one half of the problem. Clear telemetry ownership solved the other.