LangGraph integration
LangGraph reuses LangChain's callback contract, so the same Spanlens handler works for both. Every node invocation becomes a span; the runId and parentRunId on each callback give Spanlens the graph topology, which renders in two ways on /traces: as a Gantt waterfall (Timeline tab) and as a node-and-edge graph (Graph tab) with the critical path highlighted in accent color.
Install
pnpm add @spanlens/sdk
# plus your existing langgraph install:
pnpm add @langchain/langgraph @langchain/corebashMinimal setup
import { SpanlensClient } from '@spanlens/sdk'
import { createSpanlensCallbackHandler } from '@spanlens/sdk/langchain'
import { StateGraph, END } from '@langchain/langgraph'
const client = new SpanlensClient()
const handler = createSpanlensCallbackHandler({ client })
const workflow = new StateGraph(...)
.addNode('retrieve', retrieveNode)
.addNode('generate', generateNode)
.addNode('reflect', reflectNode)
.addConditionalEdges('reflect', shouldContinue, { yes: 'retrieve', no: END })
const graph = workflow.compile()
// Attach at invocation time:
const result = await graph.invoke(
{ input: question },
{ callbacks: [handler] },
)tsThat is the whole integration. The handler is safe to share across concurrent invocations (LangChain tags every run with a UUID), so one instance per process is fine.
What gets captured
| LangGraph event | Spanlens span | Default capture? |
|---|---|---|
| Graph node entry / exit | chain.<node_name>, span_type custom | yes |
| LLM call (ChatModel or LLM) | llm.<model_class>, span_type llm with token usage | yes |
| Tool call | tool.<tool_name>, span_type tool | yes |
| Retriever query | retrieval.<retriever_name>, span_type retrieval | yes |
| Conditional edge evaluation | captured as a child chain.* span on the source node | yes |
| State channel updates | not captured (would be too noisy on most graphs) | no |
Toggle individual capture categories on the handler:
const handler = createSpanlensCallbackHandler({
client,
captureChains: true, // nodes + conditional edges (default true)
captureTools: true, // tool calls (default true)
captureRetrieval: true, // retriever spans (default true)
maxInputBytes: 16_384, // truncate large inputs at 16 KB
maxOutputBytes: 16_384,
})tsParallel fan-out (Send API, parallel branches)
LangGraph's Send primitive and parallel conditional edges fire multiple nodes concurrently. Spanlens spans intentionally do not enforce a foreign key on parent_span_id, so out-of-order child closes never break the tree. The waterfall view stacks parallel branches vertically with their actual start / end timestamps.
Trace: customer-support-agent (3.2s)
└── chain.agent_orchestrator (3.2s)
├── chain.classify_intent (450ms)
│ └── llm.ChatOpenAI (430ms, gpt-4o-mini, $0.0008)
│
├── chain.dispatch (parallel) (2.7s)
│ ├── chain.lookup_order (1.1s)
│ │ ├── tool.shopify_query (840ms)
│ │ └── llm.ChatOpenAI (240ms, gpt-4o-mini, $0.0005)
│ │
│ └── chain.lookup_kb (2.7s) ← critical path
│ ├── retrieval.PineconeStore (300ms, 12 docs)
│ └── llm.ChatAnthropic (2.3s, claude-haiku-4-5, $0.0023)
│
└── chain.compose_final (40ms)textAttaching to a long-lived trace
By default the handler opens a fresh trace for each top-level invoke() call and closes it when the root run ends. To group multiple invocations (for example: every turn of a chat session) under one trace, pass an existing trace:
const trace = client.startTrace({
name: 'chat-session',
metadata: { user_id: currentUser.id, session_id: sessionId },
})
const handler = createSpanlensCallbackHandler({ client, trace })
// All turns in this session attach as child spans under one trace.
for (const userMessage of conversation) {
await graph.invoke({ input: userMessage }, { callbacks: [handler] })
}
await trace.end() // caller owns lifecycle when trace is passed intsPairing with the proxy for cost / token capture
The callback handler captures span structure but takes token counts from LangChain's llmOutput.tokenUsage, which is sometimes empty on streaming responses. To guarantee accurate cost on every LLM call, configure LangChain's OpenAI / Anthropic providers to route through the Spanlens proxy:
import { ChatOpenAI } from '@langchain/openai'
const llm = new ChatOpenAI({
model: 'gpt-4o-mini',
configuration: {
baseURL: 'https://server.spanlens.io/proxy/openai/v1',
apiKey: process.env.SPANLENS_API_KEY,
},
})tsNow every LLM call lands as a Request in ClickHouse with the canonical cost and token counts, and the corresponding LLM span links to it via request_id. The trace waterfall shows both: the structural span tree from the callback handler and authoritative cost from the proxy log.
Linking spans to prompt versions
To tag an LLM call inside the graph with a Spanlens prompt version, set thex-spanlens-prompt-version header on the underlying request. With the proxy approach above, attach it as a default header:
const llm = new ChatOpenAI({
model: 'gpt-4o-mini',
configuration: {
baseURL: 'https://server.spanlens.io/proxy/openai/v1',
apiKey: process.env.SPANLENS_API_KEY,
defaultHeaders: {
'x-spanlens-prompt-version': 'agent-system@7',
},
},
})tsThe Request row now carries prompt_version_id, so the Prompt A/B view can compare versions on production traffic.
Verifying the integration
- Invoke the graph once.
- Open /traces. A new trace appears with the graph name (default
langchain_run; override viatraceName). - Click into the trace. The waterfall mirrors your graph: one row per node, nested tool / LLM / retrieval children, parallel branches stacked at their real times.
- On any LLM row, the right panel shows token counts and cost. If
request_idis present, the row links straight to the Request in /requests.
Troubleshooting
No spans show up
Make sure you pass the handler at invocation time (in thecallbacks option of graph.invoke()), not at compile time. LangGraph compiles the graph once but invokes it many times; the handler must travel with each invocation.
LLM spans missing token usage
Streaming responses sometimes omit tokenUsage inllmOutput. The fix is to route the underlying LLM through the Spanlens proxy (see Pairing with the proxy above); the proxy parses tokens from the raw stream and the linked Request always has them.
Trace closes too early on background work
If your graph kicks off fire-and-forget work after returning, the auto-managed trace will close before that work logs. Pass an external trace and call trace.end() yourself when all work is done.
Next: Agent tracing tutorial for a runnable example, or data model for what ends up in the database.