← Back to DevBytes

Observability and Tracing with LangGraph: Complete Guide

Observability and Tracing with LangGraph: Complete Guide

Building agentic workflows with LangGraph is powerful, but as your graphs grow in complexity—with multiple nodes, conditional edges, tool calls, and LLM invocations—understanding what happens inside them becomes critical. Observability and tracing give you the visibility needed to debug, optimize, and monitor your agents in production. This guide walks through everything you need to know to make your LangGraph applications transparent and debuggable.

What Is Observability in LangGraph?

Observability is the ability to inspect the internal state and behavior of a system from the outside. In the context of LangGraph, it means tracking every step your graph takes: which nodes executed, what data flowed between them, how long each LLM call took, what tools were invoked, and where errors occurred.

Tracing is the specific technique of recording a hierarchical timeline of execution. A trace captures the full call tree—parent operations and their nested children—so you can see exactly how a single user request propagated through your graph.

LangGraph is built on top of LangChain's instrumentation layer, which means every LLM call, tool execution, and graph node is automatically traceable when you configure a tracing backend.

Why Observability Matters for Agentic Workflows

Agentic systems behave differently from traditional applications. They are non-deterministic, multi-step, and often involve reasoning that is hard to predict. Without observability, you are flying blind. Here is why it matters:

Setting Up LangSmith for Tracing

LangSmith is LangChain's official observability platform and the most straightforward way to trace LangGraph applications. It provides a visual interface for inspecting traces, comparing runs, and building evaluation datasets.

To get started, sign up for a LangSmith account and obtain your API key. Then configure your environment variables:

import os

os.environ["LANGCHAIN_TRACING_V2"] = "true"
os.environ["LANGCHAIN_API_KEY"] = "ls__your_api_key_here"
os.environ["LANGCHAIN_PROJECT"] = "my-langgraph-project"
os.environ["LANGCHAIN_ENDPOINT"] = "https://api.smith.langchain.com"

Once these environment variables are set, LangGraph automatically emits traces to LangSmith for every run. You do not need to modify your graph code at all. Let's build a simple graph and see this in action:

from langgraph.graph import StateGraph, END
from typing import TypedDict, Annotated
from langchain_openai import ChatOpenAI
from langchain_core.messages import HumanMessage
import operator

class AgentState(TypedDict):
    messages: Annotated[list, operator.add]

llm = ChatOpenAI(model="gpt-4o", temperature=0)

def call_model(state: AgentState) -> AgentState:
    response = llm.invoke(state["messages"])
    return {"messages": [response]}

def should_continue(state: AgentState) -> str:
    last_message = state["messages"][-1]
    if last_message.tool_calls:
        return "tools"
    return END

# Build the graph
workflow = StateGraph(AgentState)
workflow.add_node("agent", call_model)
workflow.set_entry_point("agent")
workflow.add_conditional_edges("agent", should_continue)
workflow.add_edge("tools", "agent")

app = workflow.compile()

# Run it — this will automatically be traced in LangSmith
result = app.invoke({
    "messages": [HumanMessage(content="What is 25 multiplied by 13?")]
})
print(result["messages"][-1].content)

After running this code, navigate to your LangSmith dashboard. You will see a trace showing the graph execution, the LLM call within the agent node, token usage, latency, and the full message history. This visibility comes for free just by setting environment variables.

Using LangGraph's Built-in Time Travel and State Inspection

Beyond external tracing tools, LangGraph itself provides powerful introspection capabilities. Every compiled graph maintains a checkpointable state, and you can inspect or replay from any point in execution.

To enable checkpointing, use a checkpointer when compiling your graph:

from langgraph.checkpoint.memory import MemorySaver

checkpointer = MemorySaver()
app = workflow.compile(checkpointer=checkpointer)

# Run with a thread_id to enable state tracking
config = {"configurable": {"thread_id": "thread-1"}}
result = app.invoke(
    {"messages": [HumanMessage(content="Hello, who are you?")]},
    config=config
)

# Inspect the full state history
for state in app.get_state_history(config):
    print(f"Step: {state.next}, Messages: {len(state.values.get('messages', []))}")
    print(f"  Created at: {state.created_at}")
    print(f"  Config: {state.config}")

This state history is invaluable for debugging. You can see every intermediate state your graph passed through. You can even replay from a specific checkpoint or modify state before resuming:

# Get the state history and pick an earlier checkpoint
states = list(app.get_state_history(config))
earlier_state = states[-2]  # Pick an earlier point

# Resume execution from that checkpoint
for event in app.stream(
    None,
    earlier_state.config,
    stream_mode="values"
):
    if "messages" in event:
        last_msg = event["messages"][-1]
        print(f"Message: {last_msg.content[:100]}...")

Custom Callbacks for Fine-Grained Tracing

Sometimes you need more control over what gets traced. LangGraph and LangChain support a callback system that lets you hook into every event. This is useful when you want to log to a custom backend, add business-specific metadata, or filter which events get recorded.

from langchain_core.callbacks import BaseCallbackHandler
from typing import Any, Dict, List, Optional
from uuid import UUID
import time
import logging

logging.basicConfig(level=logging.INFO)
logger = logging.getLogger("langgraph_tracer")

class CustomTracingHandler(BaseCallbackHandler):
    def __init__(self):
        self.start_times = {}

    def on_llm_start(
        self,
        serialized: Dict[str, Any],
        prompts: List[str],
        *,
        run_id: UUID,
        **kwargs: Any
    ) -> None:
        self.start_times[run_id] = time.time()
        model_name = serialized.get("name", "unknown")
        logger.info(f"[LLM START] Model: {model_name}, Run ID: {run_id}")

    def on_llm_end(
        self,
        response,
        *,
        run_id: UUID,
        **kwargs: Any
    ) -> None:
        duration = time.time() - self.start_times.get(run_id, time.time())
        token_usage = response.llm_output.get("token_usage", {}) if response.llm_output else {}
        logger.info(
            f"[LLM END] Run ID: {run_id}, Duration: {duration:.2f}s, "
            f"Tokens: {token_usage}"
        )

    def on_tool_start(
        self,
        serialized: Dict[str, Any],
        input_str: str,
        *,
        run_id: UUID,
        **kwargs: Any
    ) -> None:
        tool_name = serialized.get("name", "unknown")
        logger.info(f"[TOOL START] Tool: {tool_name}, Input: {input_str[:100]}")

    def on_tool_end(
        self,
        output: str,
        *,
        run_id: UUID,
        **kwargs: Any
    ) -> None:
        logger.info(f"[TOOL END] Output: {str(output)[:100]}")

    def on_chain_error(
        self,
        error: BaseException,
        *,
        run_id: UUID,
        **kwargs: Any
    ) -> None:
        logger.error(f"[CHAIN ERROR] Run ID: {run_id}, Error: {error}")

# Attach the callback to your LLM
llm_with_callbacks = ChatOpenAI(
    model="gpt-4o",
    temperature=0,
    callbacks=[CustomTracingHandler()]
)

You can also attach callbacks at the graph level by passing them in the config during invocation:

result = app.invoke(
    {"messages": [HumanMessage(content="What is the weather in Tokyo?")]},
    config={
        "callbacks": [CustomTracingHandler()],
        "configurable": {"thread_id": "thread-2"}
    }
)

Streaming for Real-Time Observability

Tracing after the fact is useful, but sometimes you need real-time visibility. LangGraph supports multiple streaming modes that let you observe execution as it happens:

# Stream mode options:
# - "values": streams the full state after each step
# - "updates": streams only the updates from each step
# - "messages": streams LLM tokens as they are generated
# - "debug": streams detailed debug events including task execution

config = {"configurable": {"thread_id": "thread-3"}}

print("=== Streaming Updates ===")
for event in app.stream(
    {"messages": [HumanMessage(content="Explain quantum computing in 2 sentences.")]},
    config=config,
    stream_mode="updates"
):
    for node_name, node_output in event.items():
        print(f"Node '{node_name}' produced:")
        if "messages" in node_output:
            for msg in node_output["messages"]:
                print(f"  {msg.type}: {msg.content[:150]}...")

print("\n=== Streaming Debug Info ===")
for event in app.stream(
    {"messages": [HumanMessage(content="What is 5 + 3?")]},
    config={"configurable": {"thread_id": "thread-4"}},
    stream_mode="debug"
):
    if event.get("type") == "task":
        print(f"Task: {event.get('name', 'unknown')}")
    elif event.get("type") == "task_result":
        print(f"Result received for: {event.get('name', 'unknown')}")

Integrating with OpenTelemetry

If your organization uses OpenTelemetry for distributed tracing, you can integrate LangGraph traces into your existing observability stack. The langfuse package and OpenTelemetry-compatible backends can capture LangChain/LangGraph events:

# Install: pip install langfuse opentelemetry-sdk opentelemetry-exporter-otlp

from langfuse.callback import CallbackHandler
from opentelemetry import trace
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import BatchSpanProcessor
from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter

# Set up OpenTelemetry
provider = TracerProvider()
processor = BatchSpanProcessor(
    OTLPSpanExporter(endpoint="http://localhost:4317")
)
provider.add_span_processor(processor)
trace.set_tracer_provider(provider)

# Set up Langfuse handler for LangGraph
langfuse_handler = CallbackHandler(
    public_key="pk-lf-your-key",
    secret_key="sk-lf-your-secret",
    host="https://cloud.langfuse.com"
)

# Use it in your graph invocation
result = app.invoke(
    {"messages": [HumanMessage(content="Summarize the French Revolution.")]},
    config={
        "callbacks": [langfuse_handler],
        "configurable": {"thread_id": "thread-5"}
    }
)

Adding Custom Metadata to Traces

To make traces more useful in production, attach metadata that helps you filter and search. This is especially valuable when you have many users or different graph configurations:

result = app.invoke(
    {"messages": [HumanMessage(content="What are the symptoms of flu?")]},
    config={
        "configurable": {"thread_id": "user-123-thread-1"},
        "metadata": {
            "user_id": "user-123",
            "session_id": "session-abc",
            "environment": "production",
            "graph_version": "v2.1.0",
            "feature_flags": {"extended_reasoning": True}
        },
        "tags": ["health-query", "production"]
    }
)

In LangSmith, you can then filter traces by these metadata fields, tags, or user IDs to find specific runs or analyze patterns across a subset of traffic.

Building a Custom Logging Wrapper for Nodes

For maximum control, you can wrap your node functions with custom logging logic. This approach works well when you want structured logs alongside traces:

import functools
import logging
import json
from datetime import datetime

logger = logging.getLogger("langgraph_nodes")
logger.setLevel(logging.DEBUG)

def trace_node(func):
    """Decorator that adds logging and tracing to graph nodes."""
    @functools.wraps(func)
    def wrapper(state):
        node_name = func.__name__
        start = datetime.now()
        logger.info(f"Entering node '{node_name}'")

        # Log incoming state (truncate large values)
        try:
            state_summary = {
                k: str(v)[:200] if not isinstance(v, list) else f"[{len(v)} items]"
                for k, v in state.items()
            }
            logger.debug(f"Node '{node_name}' input state: {json.dumps(state_summary, default=str)}")
        except Exception as e:
            logger.warning(f"Could not serialize state for node '{node_name}': {e}")

        try:
            result = func(state)
            duration = (datetime.now() - start).total_seconds()
            logger.info(f"Node '{node_name}' completed in {duration:.3f}s")

            # Log output summary
            if isinstance(result, dict):
                output_keys = list(result.keys())
                logger.debug(f"Node '{node_name}' output keys: {output_keys}")

            return result
        except Exception as e:
            duration = (datetime.now() - start).total_seconds()
            logger.error(
                f"Node '{node_name}' failed after {duration:.3f}s: {e}",
                exc_info=True
            )
            raise

    return wrapper

# Apply the decorator to your nodes
@trace_node
def call_model(state: AgentState) -> AgentState:
    response = llm.invoke(state["messages"])
    return {"messages": [response]}

@trace_node
def call_tools(state: AgentState) -> AgentState:
    # Tool execution logic here
    return {"messages": []}

Best Practices for LangGraph Observability

Putting It All Together: A Production-Ready Setup

Here is a complete example that combines checkpointing, LangSmith tracing, custom callbacks, and metadata into a production-ready configuration:

import os
from langgraph.graph import StateGraph, END
from langgraph.checkpoint.memory import MemorySaver
from langchain_openai import ChatOpenAI
from langchain_core.messages import HumanMessage, SystemMessage
from typing import TypedDict, Annotated
import operator
import logging

# Configure tracing
os.environ["LANGCHAIN_TRACING_V2"] = "true"
os.environ["LANGCHAIN_API_KEY"] = "ls__your_api_key"
os.environ["LANGCHAIN_PROJECT"] = "production-agent"

logging.basicConfig(level=logging.INFO)
logger = logging.getLogger("agent")

class AgentState(TypedDict):
    messages: Annotated[list, operator.add]
    iteration_count: int

llm = ChatOpenAI(model="gpt-4o", temperature=0)

def agent_node(state: AgentState) -> AgentState:
    logger.info(f"Agent iteration: {state.get('iteration_count', 0)}")
    system_prompt = SystemMessage(content=(
        "You are a helpful assistant. Answer concisely. "
        "If you need to use a tool, do so. Otherwise, provide a direct answer."
    ))
    messages = [system_prompt] + state["messages"]
    response = llm.invoke(messages)
    return {
        "messages": [response],
        "iteration_count": state.get("iteration_count", 0) + 1
    }

def should_continue(state: AgentState) -> str:
    last_message = state["messages"][-1]
    if hasattr(last_message, "tool_calls") and last_message.tool_calls:
        if state.get("iteration_count", 0) >= 10:
            logger.warning("Max iterations reached, stopping.")
            return END
        return "tools"
    return END

def tools_node(state: AgentState) -> AgentState:
    # Placeholder for actual tool execution
    return {"iteration_count": state.get("iteration_count", 0) + 1}

# Build and compile with checkpointing
workflow = StateGraph(AgentState)
workflow.add_node("agent", agent_node)
workflow.add_node("tools", tools_node)
workflow.set_entry_point("agent")
workflow.add_conditional_edges("agent", should_continue)
workflow.add_edge("tools", "agent")

checkpointer = MemorySaver()
app = workflow.compile(checkpointer=checkpointer)

# Production invocation with full observability
def run_agent(user_input: str, user_id: str, session_id: str):
    config = {
        "configurable": {"thread_id": f"{user_id}-{session_id}"},
        "metadata": {
            "user_id": user_id,
            "session_id": session_id,
            "environment": os.getenv("ENV", "development"),
            "graph_version": "1.0.0",
        },
        "tags": ["user-facing", "conversational"]
    }

    result = app.invoke(
        {"messages": [HumanMessage(content=user_input)], "iteration_count": 0},
        config=config
    )

    return result["messages"][-1].content

# Usage
response = run_agent(
    user_input="What is the capital of France?",
    user_id="user-456",
    session_id="session-001"
)
print(f"Response: {response}")

Observability is not an afterthought—it is a foundational part of building reliable agentic systems. By combining LangGraph's built-in checkpointing and state inspection with external tracing platforms like LangSmith, custom callbacks, and structured logging, you gain complete visibility into how your agents behave. This visibility is what separates prototypes from production systems. Start with basic tracing enabled from your first graph, layer in custom instrumentation as complexity grows, and use the insights from traces to continuously improve your agent's reasoning, tool usage, and performance. With the practices and patterns covered in this guide, you are well-equipped to build LangGraph applications that are not only powerful but also transparent, debuggable, and production-ready.

— Ad —

Google AdSense will appear here after approval

← Back to all articles