LangGraph Integration#
LangGraph is a graph-based stateful multi-agent framework built on top of LangChain. Langfuse integrates with it via two paths:
- LangChain CallbackHandler — primary integration, captures all LangGraph node executions, LLM calls, and tool calls as a nested trace hierarchy.
- OTEL via
LangfuseSpanProcessor— for JS/TS environments using the OpenTelemetry SDK.
Two recurring issues affect LangGraph traces specifically: control-flow exceptions being misclassified as errors (GraphInterrupt, GraphBubbleUp) and trace fragmentation across interrupt-resume cycles in human-in-the-loop (HITL) workflows.
Primary reference: LangChain & LangGraph integration docs · LangGraph example notebook
LangChain Callback Handler#
The CallbackHandler is passed to graph.stream() or graph.invoke() via the config parameter :
# Python
from langfuse.langchain import CallbackHandler
langfuse_handler = CallbackHandler()
graph.stream(input, config={"callbacks": [langfuse_handler]})
// JS/TS
import { CallbackHandler } from "@langfuse/langchain";
const langfuseHandler = new CallbackHandler();
await graph.stream(input, { callbacks: [langfuseHandler] });
Trace attributes (user_id, session_id, tags) can be set via the metadata field in config (Python: langfuse_user_id, langfuse_session_id; JS: langfuseUserId, langfuseSessionId) or via propagate_attributes() context manager .
Internal usage: Langfuse uses the same CallbackHandler internally for tracing LLM calls within features like the playground. getInternalTracingHandler.ts instantiates the handler with _isLocalEventExportEnabled: true to capture events locally. Before ingestion, it filters out noisy intermediate spans — RunnableLambda, StructuredOutputParser, StrOutputParser, JsonOutputParser — via a blocklist .
Serverless note (JS/TS): Since LangChain > 0.3.0, callbacks run in the background. In Lambda/Cloud Functions, set LANGCHAIN_CALLBACKS_BACKGROUND=false or call awaitAllCallbacks() before exit .
OTEL Ingestion: GraphInterrupt and GraphBubbleUp#
Problem#
LangGraph HITL flows raise GraphInterrupt and GraphBubbleUp as standard Python exceptions that propagate up through the OTel instrumentation. These arrive as OTEL spans with status.code === 2 (ERROR) and an exception event whose exception.type is langgraph.errors.GraphInterrupt or langgraph.errors.GraphBubbleUp. Prior to the fix, OtelIngestionProcessor unconditionally mapped any status.code === 2 span to ObservationLevel.ERROR, causing normal HITL pauses to appear as failed traces in the Langfuse UI .
Fix (PR #14239)#
PR #14239 adds isLangGraphControlFlowInterruptSpan() to packages/shared/src/server/otel/utils.ts. The function:
- Short-circuits to
falseifspan.status?.code !== 2 - Iterates exception events on the span looking for an
exception.typeattribute whosestringValueincludeslanggraph.errors.GraphInterruptorlanggraph.errors.GraphBubbleUp - Returns
trueif a match is found
This guard is applied in two places in OtelIngestionProcessor.ts — for both span-type and tool-type observations — before the existing status.code === 2 → ERROR branch . When it returns true, the observation is kept at ObservationLevel.DEFAULT instead of ERROR.
Caveat: The
statusMessagefield is not suppressed for interrupt spans, so the UI may still display the interrupt's payload description (e.g.,"Interrupt value: {...}") on an otherwiseDEFAULT-level observation .
Python SDK Fix (earlier)#
The Python SDK-level fix (handling GraphBubbleUp in the LangChain callback handler) shipped in v2.60.4 . The OTEL-path fix in PR #14239 covers the OTEL ingestion layer independently.
Trace Continuity in Human-in-the-Loop Workflows#
Problem#
Each call to graph.stream() or graph.invoke() creates a new LangChain run with a new run_id. When a HITL workflow is interrupted and resumed with Command(resume=...), LangGraph starts a fresh run — Langfuse sees a second run_id and creates a separate, orphaned trace. The result: interrupt call → Trace A (often marked ERROR); resume call → Trace B (partial) .
Solution: Predefined Trace ID#
Pin both calls to the same trace by supplying a deterministic trace_id before the first invocation. The official pattern uses Langfuse.create_trace_id(seed=...) with a stable external ID (e.g., a session or thread ID) so the same ID is reproducible without storing it :
from langfuse import get_client, Langfuse
from langfuse.langchain import CallbackHandler
langfuse = get_client()
trace_id = Langfuse.create_trace_id(seed="langgraph-thread-42")
# First invocation
with langfuse.start_as_current_observation(
as_type="span", name="hitl-run",
trace_context={"trace_id": trace_id}
):
langfuse_handler = CallbackHandler()
graph.stream(initial_input, config={"callbacks": [langfuse_handler]})
# Later, after human response
with langfuse.start_as_current_observation(
as_type="span", name="hitl-resume",
trace_context={"trace_id": trace_id}
):
langfuse_handler = CallbackHandler()
graph.stream(Command(resume=human_response), config={"callbacks": [langfuse_handler]})
A simpler community workaround (no context manager) passes a fixed trace_id directly to CallbackHandler on each invocation :
import uuid
trace_id = str(uuid.uuid4()) # store this per user session
langfuse_handler = CallbackHandler(trace_id=trace_id)
graph.stream(initial_input, config={"callbacks": [langfuse_handler]})
# Resume
langfuse_handler = CallbackHandler(trace_id=trace_id)
graph.stream(Command(resume=human_response), config={"callbacks": [langfuse_handler]})
Both approaches append observations from both graph.stream() calls to the same trace.
Key Files and References#
| File | Purpose |
|---|---|
packages/shared/src/server/otel/OtelIngestionProcessor.ts | Core OTEL span → observation conversion; location of isLangGraphControlFlowInterruptSpan guard |
packages/shared/src/server/otel/utils.ts | isLangGraphControlFlowInterruptSpan() helper (added in PR #14239) |
packages/shared/src/server/llm/getInternalTracingHandler.ts | Langfuse-internal LangChain callback handler setup; span blocklist |
packages/shared/src/utils/chatml/adapters/langgraph.ts | LangGraph message format normalization for ChatML (tool calls, message types) |
External references: