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/core
bash

Minimal 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] },
)
ts

That 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 eventSpanlens spanDefault capture?
Graph node entry / exitchain.<node_name>, span_type customyes
LLM call (ChatModel or LLM)llm.<model_class>, span_type llm with token usageyes
Tool calltool.<tool_name>, span_type toolyes
Retriever queryretrieval.<retriever_name>, span_type retrievalyes
Conditional edge evaluationcaptured as a child chain.* span on the source nodeyes
State channel updatesnot 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,
})
ts

Parallel 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)
text

Attaching 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 in
ts

Pairing 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,
  },
})
ts

Now 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',
    },
  },
})
ts

The Request row now carries prompt_version_id, so the Prompt A/B view can compare versions on production traffic.

Verifying the integration

  1. Invoke the graph once.
  2. Open /traces. A new trace appears with the graph name (default langchain_run; override via traceName).
  3. 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.
  4. On any LLM row, the right panel shows token counts and cost. If request_id is 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.