---
title: "How to Monitor a LangGraph Agent: Logs, Token Cost, Traces and Alerts"
description: "A runnable LangGraph monitoring setup on langgraph 1.2.15: JSON logs for every node, token usage and cost per run, OpenTelemetry traces with no account, and alerts for runaway loops."
author: "Jahanzaib Ahmed"
date: 2026-10-14
category: "ai-agents"
readingTime: "20 min read"
tags: ["langgraph", "langsmith", "observability", "ai-agents", "python"]
canonical: https://www.jahanzaib.ai/blog/langgraph-monitoring-logging
source: https://www.jahanzaib.ai
---
# How to Monitor a LangGraph Agent: Logs, Token Cost, Traces and Alerts

LangGraph monitoring comes down to four signals: what each node did, how many tokens each run burned, where a run went wrong, and whether a run is looping. That last one matters more than it looks, because on langgraph 1.2.15 a looping agent runs 10,007 steps before LangGraph stops it. This guide wires all four signals into a small agent with no API key and no paid calls, and every output below is pasted from my terminal. It is for Python developers who have a graph working on their laptop and now need to see it in production.

If you are still building the agent itself, start with my [LangGraph tutorial for production AI agents](https://www.jahanzaib.ai/blog/langgraph-tutorial-build-production-ai-agents). This page covers what that one skips: watching the graph once it runs.

## Before you start

-   Python 3.11 or newer. I used Python 3.13.12 in a fresh virtual environment.
-   langgraph 1.2.15, langchain-core 1.6.10 and langsmith 0.14.8, the current releases on PyPI on 15 October 2026. The OpenTelemetry step adds the `otel` extra of langsmith.
-   No model API key. The test model below replays scripted replies that carry realistic usage numbers, so every cost figure is computed, never billed.
-   Two files in one folder: `agent.py` (Step 2) and `monitor.py` (Steps 4 to 6). Each code block below says which file it belongs to.
-   Optional: a LangSmith or Langfuse account for Step 7. The free tiers of both are enough to follow along.
-   Comfort with a basic [agentic loop](https://www.jahanzaib.ai/glossary/agentic-loop): a model node, a tool node and a router between them.

![LangGraph monitoring flow from a graph run through callbacks to JSON logs, token cost, OpenTelemetry spans, alerts and a trace backend](https://cdn.sanity.io/images/qajb7q5q/production/b5eb545cc8970e7cfc390ff779900457c04a3824-1376x768.png?w=1200&q=75&auto=format&fit=max)

_Callbacks are the single tap point: the same run feeds your logs, your cost ledger and your trace backend without touching node code._

## Step 1: Install a pinned LangGraph monitoring stack

Pin the versions. Monitoring code reads metadata keys and environment variables that move between releases, and this guide found three places where the installed release and the docs disagree.

```
python3.13 -m venv venv
source venv/bin/activate
pip install "langgraph==1.2.15" "langchain-core==1.6.10" "langsmith[otel]==0.14.8"
```

Check it worked with `pip list | grep lang`. You should see langgraph-prebuilt 1.1.0 and langgraph-checkpoint 4.3.0 pulled in alongside the three packages above.

## Step 2: Build a test agent whose model reports token usage

You cannot test cost tracking with a model that reports no usage, and you should not test it with one that bills you. My first attempt used LangChain's `GenericFakeChatModel`. It works with `invoke`, but the moment I streamed with `stream_mode="messages"` it crashed on the tool call turn with `ValueError: No generations found in stream`. So I wrote a 30 line scripted model that streams text word by word and puts the usage on its last chunk, the way real provider integrations behave. Save this as `agent.py`:

```
# agent.py
import itertools
import json
from typing import Any, Iterator

from langchain_core.language_models import BaseChatModel
from langchain_core.messages import AIMessage, AIMessageChunk
from langchain_core.outputs import ChatGeneration, ChatGenerationChunk, ChatResult
from langchain_core.tools import tool
from langgraph.graph import StateGraph, START, MessagesState
from langgraph.managed import RemainingSteps
from langgraph.prebuilt import ToolNode, tools_condition

MODEL = "claude-sonnet-5-5"

TOOL_CALL = AIMessage(
    content="",
    tool_calls=[{"name": "get_order_status", "args": {"order_id": "A-1042"}, "id": "call_1"}],
    usage_metadata={"input_tokens": 812, "output_tokens": 46, "total_tokens": 858,
                    "input_token_details": {"cache_read": 512}},
    response_metadata={"model_name": MODEL},
)
ANSWER = AIMessage(
    content="Order A-1042 shipped on 13 October and should arrive on 16 October.",
    usage_metadata={"input_tokens": 905, "output_tokens": 61, "total_tokens": 966,
                    "input_token_details": {"cache_read": 768}},
    response_metadata={"model_name": MODEL},
)


class ScriptedChatModel(BaseChatModel):
    replies: Iterator[AIMessage]

    @property
    def _llm_type(self) -> str:
        return "scripted"

    def _next(self) -> AIMessage:
        # A fresh copy each call, so every reply gets its own message id.
        return next(self.replies).model_copy(deep=True)

    def _generate(self, messages, stop=None, run_manager=None, **kwargs: Any) -> ChatResult:
        return ChatResult(generations=[ChatGeneration(message=self._next())])

    def _stream(self, messages, stop=None, run_manager=None, **kwargs: Any):
        msg = self._next()
        for word in msg.content.split(" ") if msg.content else []:
            chunk = ChatGenerationChunk(message=AIMessageChunk(content=word + " "))
            if run_manager:
                run_manager.on_llm_new_token(word + " ", chunk=chunk)
            yield chunk
        yield ChatGenerationChunk(message=AIMessageChunk(
            content="",
            tool_call_chunks=[{"name": c["name"], "args": json.dumps(c["args"]),
                               "id": c["id"], "index": i}
                              for i, c in enumerate(msg.tool_calls)],
            usage_metadata=msg.usage_metadata,
            response_metadata=msg.response_metadata,
        ))


def happy_path_model():
    return ScriptedChatModel(replies=iter([TOOL_CALL, ANSWER]))


def looping_model():
    """Never stops calling the tool: the classic runaway agent."""
    return ScriptedChatModel(replies=itertools.cycle([TOOL_CALL]))


@tool
def get_order_status(order_id: str) -> str:
    """Look up the shipping status of an order."""
    return f"{order_id}: shipped 2026-10-13, ETA 2026-10-16"


class State(MessagesState):
    remaining_steps: RemainingSteps


def build_graph(model=None, checkpointer=None, guard=True):
    model = model or happy_path_model()

    def agent(state: State):
        if guard and state["remaining_steps"] < 3:
            return {"messages": [AIMessage(content="I ran out of steps. Escalating to a human.")]}
        return {"messages": [model.invoke(state["messages"])]}

    builder = StateGraph(State)
    builder.add_node("agent", agent)
    builder.add_node("tools", ToolNode([get_order_status]))
    builder.add_edge(START, "agent")
    builder.add_conditional_edges("agent", tools_condition)
    builder.add_edge("tools", "agent")
    return builder.compile(checkpointer=checkpointer)
```

The `model_copy` line is there because of a bug I hit. When the model returned the same `AIMessage` object twice, both replies carried the same id, and LangGraph's message reducer replaced the old message instead of appending a new one. The loop quietly ended a turn early. Real models never repeat an id, but test doubles do. The usage numbers in `TOOL_CALL` and `ANSWER` are made up but shaped like a real Claude response, including cache reads. The `RemainingSteps` field and the `guard` flag come back in Step 6.

## Step 3: Watch a run with stream modes and debug mode

Before you add any tooling, LangGraph already tells you a lot through `graph.stream`. The [streaming docs](https://docs.langchain.com/oss/python/langgraph/streaming) list seven stream modes. Pass `version="v2"` and every chunk comes back as a dict with `type`, `ns` and `data`, which is far easier to route than the older tuples.

| Mode | What you get | Use it for | Checkpointer on 1.2.15 |
| --- | --- | --- | --- |
| updates | What each node changed, per step | Production event logs | Not needed |
| values | The full state after each step | Debugging state bugs locally | Not needed |
| messages | LLM tokens plus metadata such as langgraph_node | Chat UIs, per node token streams | Not needed |
| custom | Whatever you emit with get_stream_writer | Progress events from inside tools | Not needed |
| tasks | Task start and finish, with results and errors | Spotting which node failed | Docs say yes; it ran without one |
| debug | Checkpoints plus tasks with extra metadata | Deep local debugging only | Only for the checkpoint events |

The last column is what I measured, not what the docs say. Without a checkpointer, `tasks` streamed all six task events of a run, `debug` streamed the same six, and the seventh mode, `checkpoints`, streamed nothing. This loop prints the `updates` stream:

```
# step_stream.py
from langchain_core.messages import HumanMessage
from agent import build_graph

inputs = {"messages": [HumanMessage("Where is order A-1042?")]}
for part in build_graph().stream(inputs, stream_mode="updates", version="v2"):
    for node, update in part["data"].items():
        msg = update["messages"][-1]
        print(f"step from {node}: {type(msg).__name__} {msg.content[:50]!r} "
              f"usage={getattr(msg, 'usage_metadata', None)}")
```

```
step from agent: AIMessage '' usage={'input_tokens': 812, 'output_tokens': 46, 'total_tokens': 858, 'input_token_details': {'cache_read': 512}}
step from tools: ToolMessage 'A-1042: shipped 2026-10-13, ETA 2026-10-16' usage=None
step from agent: AIMessage 'Order A-1042 shipped on 13 October and should arri' usage={'input_tokens': 905, 'output_tokens': 61, 'total_tokens': 966, 'input_token_details': {'cache_read': 768}}
```

Switch to `stream_mode="messages"`, where each `part["data"]` is a `(chunk, metadata)` pair, and you see something people get wrong when they track usage. The text chunks carry no usage at all. It arrives on one chunk near the end of each model call, and an empty chunk follows it:

```
AIMessageChunk 'Order ' usage= None node= agent step= 3
AIMessageChunk 'A-1042 ' usage= None node= agent step= 3
...
AIMessageChunk 'October. ' usage= None node= agent step= 3
AIMessageChunk '' usage= {'input_tokens': 905, 'output_tokens': 61, 'total_tokens': 966, 'input_token_details': {'cache_read': 768}} node= agent step= 3
AIMessageChunk '' usage= None node= agent step= 3
```

So never count stream chunks as tokens. And `debug` mode, compiled with `InMemorySaver()` and a `thread_id`, gives you the shape of every task:

```
checkpoint step -1 name= None error= None keys= ['config', 'metadata', 'next', 'parent_config', 'tasks', 'values']
checkpoint step 0 name= None error= None keys= ['config', 'metadata', 'next', 'parent_config', 'tasks', 'values']
task step 1 name= agent error= None keys= ['id', 'input', 'name', 'triggers']
task_result step 1 name= agent error= None keys= ['error', 'id', 'interrupts', 'name', 'result']
```

Every `task_result` carries an `error` field. That is handy locally, but checkpoint events ship the full state, so I keep debug mode off in production and use `updates` instead. The docs now recommend the newer event streaming API for new apps. The stream mode API above still works on 1.2.15 and is what most existing code uses.

## Step 4: Log every node as JSON with a callback handler

Streams only help the code that consumes them. For logs that reach your log platform no matter who calls the graph, attach a callback handler through the run config. LangGraph puts `langgraph_node` and `langgraph_step` into the metadata of every callback, so one small class covers every node. Start `monitor.py` with this:

```
# monitor.py
import json
import logging
import sys
import time
import uuid

from langchain_core.callbacks import BaseCallbackHandler, UsageMetadataCallbackHandler
from langchain_core.messages import HumanMessage
from langgraph.errors import GraphRecursionError

from agent import build_graph, looping_model

logging.basicConfig(stream=sys.stdout, level=logging.INFO, format="%(message)s")
log = logging.getLogger("agent")


def emit(level, event, **fields):
    log.log(level, json.dumps({"event": event, **fields}))


def alert(**fields):
    # Swap this for your pager, Slack webhook or email. Keep it out of the hot path.
    emit(logging.CRITICAL, "ALERT", **fields)


class NodeLogger(BaseCallbackHandler):
    def __init__(self, run_id):
        self.run_id = run_id
        self.started = {}

    def on_chain_start(self, serialized, inputs, *, run_id, metadata=None, **kwargs):
        node = (metadata or {}).get("langgraph_node")
        if node and kwargs.get("name") == node:  # skip runnables nested inside a node
            self.started[run_id] = (node, metadata.get("langgraph_step"), time.perf_counter())

    def on_chain_end(self, outputs, *, run_id, **kwargs):
        if run_id in self.started:
            node, step, t0 = self.started.pop(run_id)
            emit(logging.INFO, "node_end", run=self.run_id, node=node, step=step,
                 ms=round((time.perf_counter() - t0) * 1000, 2))

    def on_chain_error(self, error, *, run_id, **kwargs):
        if run_id in self.started:
            node, step, _ = self.started.pop(run_id)
            emit(logging.ERROR, "node_error", run=self.run_id, node=node, step=step,
                 error=type(error).__name__)
```

The name check matters. In my test run, five chain callbacks fired inside nodes but only three were nodes; the other two were the `tools_condition` router after each agent step. One JSON object per line means Datadog, CloudWatch, Loki or plain `jq` can parse it without a custom pattern. Here is a normal run:

```
{"event": "node_end", "run": "cfe4d16f", "node": "agent", "step": 1, "ms": 0.63}
{"event": "node_end", "run": "cfe4d16f", "node": "tools", "step": 2, "ms": 0.43}
{"event": "node_end", "run": "cfe4d16f", "node": "agent", "step": 3, "ms": 0.33}
```

I never log message content at INFO level. Prompts carry customer data, and logs get copied to more places than traces do.

## Step 5: Track token usage and cost per run

LangChain ships a `UsageMetadataCallbackHandler` (documented in the [models guide](https://docs.langchain.com/oss/python/langchain/models)) that sums `usage_metadata` across every model call in a run, grouped by model name. Create a fresh one per run, or totals leak between users. Then price it yourself. I use the [Anthropic pricing page](https://platform.claude.com/docs/en/about-claude/pricing) for Claude Sonnet 5.5: $2 per million input tokens, $10 per million output tokens, and $0.10 per million for cache reads. Add this to `monitor.py`:

```
# monitor.py (continued)
PRICES = {"claude-sonnet-5-5": {"input": 2.00, "cache_read": 0.10, "output": 10.00}}


def run_cost(usage_by_model):
    total = 0.0
    for model, u in usage_by_model.items():
        p = PRICES[model]
        cached = u.get("input_token_details", {}).get("cache_read", 0)
        fresh = u["input_tokens"] - cached  # input_tokens already includes cache reads
        total += (fresh * p["input"] + cached * p["cache_read"]
                  + u["output_tokens"] * p["output"]) / 1_000_000
    return round(total, 6)
```

That subtraction is the easiest line to get wrong. In LangChain's usage format, `input_tokens` is the total and `cache_read` is a subset of it. Cache writes sit inside `input_tokens` too, as `cache_creation`, and Sonnet 5.5 bills them at $2.50 per million for the 5 minute cache and $4 for the 1 hour cache, so add that price if you turn caching on. Anthropic's raw API response is different: its `input_tokens` excludes both cache counts, so only subtract when you read LangChain's `usage_metadata`. Bill the whole input at the full rate and this example costs $0.004504 instead of $0.002072, about twice the real figure, and the gap widens as more of the prompt is cached. The run wrapper records usage in a `finally` block, so failed runs get costed too:

```
# monitor.py (continued)
def run_agent(graph, question, user_id, recursion_limit=None, budget_usd=0.05):
    run_id = str(uuid.uuid4())[:8]
    usage = UsageMetadataCallbackHandler()
    config = {
        "callbacks": [NodeLogger(run_id), usage],
        "run_name": "order-agent",
        "tags": ["prod", "order-agent"],
        "metadata": {"user_id": user_id, "run": run_id},
    }
    if recursion_limit:
        config["recursion_limit"] = recursion_limit
    t0 = time.perf_counter()
    try:
        result = graph.invoke({"messages": [HumanMessage(question)]}, config)
    except GraphRecursionError as e:
        alert(run=run_id, reason="recursion_limit", detail=str(e).splitlines()[0])
        return None
    except Exception as e:
        alert(run=run_id, reason="exception", error=type(e).__name__, detail=str(e)[:200])
        raise
    finally:
        cost = run_cost(usage.usage_metadata)
        emit(logging.INFO, "run_end", run=run_id, user=user_id,
             ms=round((time.perf_counter() - t0) * 1000, 1),
             usage=usage.usage_metadata, cost_usd=cost)
        if cost > budget_usd:
            alert(run=run_id, reason="over_budget", cost_usd=cost, budget_usd=budget_usd)
    return result


if __name__ == "__main__":
    out = run_agent(build_graph(), "Where is order A-1042?", user_id="u_17")
    print("answer:", out["messages"][-1].content)
```

Run `python monitor.py`. After the three node lines from Step 4, the run ends with this:

```
{"event": "run_end", "run": "cfe4d16f", "user": "u_17", "ms": 3.6, "usage": {"claude-sonnet-5-5": {"output_tokens": 107, "total_tokens": 1824, "input_tokens": 1717, "input_token_details": {"cache_read": 1280}}}, "cost_usd": 0.002072}
answer: Order A-1042 shipped on 13 October and should arrive on 16 October.
```

Check the math: 437 fresh input tokens at $2, 1,280 cached at $0.10 and 107 output at $10 per million comes to $0.002072. If the handler hands you an empty dict on a real model, the integration is not reporting usage while streaming. On `ChatOpenAI` the fix reported on the [LangChain forum](https://forum.langchain.com/t/how-to-obtain-token-usage-from-langgraph/1727) is `stream_usage=True`.

## Step 6: Alert on recursion limits and errors

A runaway loop is the failure that costs real money, and the default limit on 1.2.15 is 10,007 steps. The [Graph API docs](https://docs.langchain.com/oss/python/langgraph/graph-api) say the default became 1,000 steps in version 1.0.6, and older guides still quote 25. I ran the looping model with the guard off and no limit set, through a fresh `UsageMetadataCallbackHandler`:

```
langgraph 1.2.15 | langchain-core 1.6.10
Recursion limit of 10007 reached without hitting a stop condition. You can increase the limit by setting the `recursion_limit` config key.
took 63.3s
model calls billed: 5004 | tokens: 4293432 | cost_usd: 5.560445
```

The 10,007 comes from [langgraph's config module](https://github.com/langchain-ai/langgraph/blob/main/libs/langgraph/langgraph/_internal/_config.py), where the `LANGGRAPH_DEFAULT_RECURSION_LIMIT` environment variable can override it. One stuck request made 5,004 model calls and $5.56 of Sonnet 5.5 spend, and that is a floor: my scripted context never grows, while a real conversation gets longer with every loop. Set `recursion_limit` in every production config, sized to your longest legitimate path plus a margin.

Then give the graph a chance to stop on its own. `RemainingSteps` is a managed value LangGraph fills in for each step, and the agent node from Step 2 checks it before calling the model.

![LangGraph recursion guard where the agent node checks remaining steps, calls the model when there are plenty and escalates when under 3](https://cdn.sanity.io/images/qajb7q5q/production/5ebce398274149af6944ee7f6a5ed94176920adf-1376x768.png?w=1200&q=75&auto=format&fit=max)

_Checking RemainingSteps inside the node turns a hard GraphRecursionError into an answer you control._

To see both cases, add two runs to the `__main__` block of `monitor.py`. The first has a tight cost budget so the budget alert fires too:

```
# monitor.py, inside if __name__ == "__main__":
run_agent(build_graph(model=looping_model(), guard=False), "Where is order A-1042?",
          user_id="u_17", recursion_limit=6, budget_usd=0.003)

out = run_agent(build_graph(model=looping_model()), "Where is order A-1042?",
                user_id="u_17", recursion_limit=6)
print("answer:", out["messages"][-1].content)
```

Without the guard, the run hits the limit, the wrapper raises two alerts, and the usage still lands in the log:

```
{"event": "node_end", "run": "ae1292a8", "node": "tools", "step": 6, "ms": 0.25}
{"event": "ALERT", "run": "ae1292a8", "reason": "recursion_limit", "detail": "Recursion limit of 6 reached without hitting a stop condition. You can increase the limit by setting the `recursion_limit` config key."}
{"event": "run_end", "run": "ae1292a8", "user": "u_17", "ms": 2.6, "usage": {"claude-sonnet-5-5": {"output_tokens": 138, "total_tokens": 2574, "input_tokens": 2436, "input_token_details": {"cache_read": 1536}}}, "cost_usd": 0.003334}
{"event": "ALERT", "run": "ae1292a8", "reason": "over_budget", "cost_usd": 0.003334, "budget_usd": 0.003}
```

With the guard, the agent escalates at step 5 and the user gets a real reply instead of a stack trace:

```
{"event": "node_end", "run": "5a6318a0", "node": "agent", "step": 5, "ms": 0.06}
{"event": "run_end", "run": "5a6318a0", "user": "u_17", "ms": 1.8, "usage": {"claude-sonnet-5-5": {"output_tokens": 92, "total_tokens": 1716, "input_tokens": 1624, "input_token_details": {"cache_read": 1024}}}, "cost_usd": 0.002222}
answer: I ran out of steps. Escalating to a human.
```

The `alert` function only writes a CRITICAL log line. In production, point your log platform's alert rule at `"event": "ALERT"`, or post to a webhook from a background queue so a slow pager never slows a user's request. If you pause runs for review rather than escalate, my guide to [human approval in LangGraph](https://www.jahanzaib.ai/blog/langgraph-human-in-the-loop) shows how to resume them after a restart.

## Step 7: Send traces to LangSmith, Langfuse or OpenTelemetry

Logs tell you that something happened. Traces show the whole tree of a run: every node, model call and tool call with inputs, outputs, latency and tokens. That is the core of agent [observability](https://www.jahanzaib.ai/glossary/observability), and you have three practical routes.

**LangSmith.** Set these environment variables and every LangGraph run is traced, with no code change:

```
export LANGSMITH_TRACING=true
export LANGSMITH_API_KEY=<your key>
export LANGSMITH_PROJECT=order-agent-prod
```

The `run_name`, `tags` and `metadata` from Step 5 show up on each trace, so you can filter by user. LangSmith also has built in [alerts](https://docs.langchain.com/langsmith/alerts) on run count, cost, errors, feedback score and latency, routed to Slack, PagerDuty or a webhook. If you run graphs locally, my [LangGraph Studio setup guide](https://www.jahanzaib.ai/blog/langgraph-studio-setup-debug-agents) covers the visual debugger that reads the same traces.

**Langfuse.** It is open source, and the [LangGraph integration](https://langfuse.com/integrations/frameworks/langgraph) is one more callback in the same list as mine. Install `langfuse`, set `LANGFUSE_PUBLIC_KEY`, `LANGFUSE_SECRET_KEY` and `LANGFUSE_BASE_URL`, and add the handler where `run_agent` builds its config. I confirmed the import and constructor on langfuse 5.0.1:

```
# monitor.py, inside run_agent
from langfuse.langchain import CallbackHandler

config = {
    "callbacks": [NodeLogger(run_id), usage, CallbackHandler()],
    # run_name, tags and metadata as before
}
```

**Any [OpenTelemetry](https://www.jahanzaib.ai/glossary/opentelemetry) backend.** This is the route I tested end to end, because it needs no account. LangSmith's SDK can emit OpenTelemetry spans instead of sending anything to LangSmith. The [OpenTelemetry guide](https://docs.langchain.com/langsmith/trace-with-opentelemetry) uses `LANGSMITH_OTEL_ENABLED` plus `LANGSMITH_OTEL_ONLY`. On langsmith 0.14.8 those still work, and the source also reads a newer `LANGSMITH_TRACING_MODE` variable that takes precedence when both are set. Set the variables and the tracer provider before you import anything from LangChain:

```
# otel_console.py
import os
os.environ["LANGSMITH_TRACING"] = "true"
os.environ["LANGSMITH_TRACING_MODE"] = "otel"  # nothing goes to LangSmith

from opentelemetry import trace
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import SimpleSpanProcessor
from opentelemetry.sdk.trace.export.in_memory_span_exporter import InMemorySpanExporter

exporter = InMemorySpanExporter()  # use OTLPSpanExporter(endpoint=...) in production
provider = TracerProvider()
provider.add_span_processor(SimpleSpanProcessor(exporter))
trace.set_tracer_provider(provider)

from langchain_core.messages import HumanMessage
from langchain_core.tracers.langchain import wait_for_all_tracers
from agent import build_graph

build_graph().invoke({"messages": [HumanMessage("Where is order A-1042?")]},
                     {"run_name": "order-agent", "metadata": {"user_id": "u_17"}})
wait_for_all_tracers()  # spans are handed over on a background thread
provider.force_flush()

for span in sorted(exporter.get_finished_spans(), key=lambda s: s.start_time):
    keep = {k: v for k, v in span.attributes.items()
            if k.startswith("gen_ai.usage") or k in ("langsmith.span.kind", "langsmith.metadata.user_id")}
    print(f"{span.name:<18} parent={'-' if not span.parent else 'yes'}  {keep}")
```

With no LangSmith key in the environment, that printed nine spans:

```
order-agent        parent=-  {'langsmith.span.kind': 'chain', 'langsmith.metadata.user_id': 'u_17'}
agent              parent=yes  {'langsmith.span.kind': 'chain', 'langsmith.metadata.user_id': 'u_17'}
ScriptedChatModel  parent=yes  {'langsmith.span.kind': 'llm', 'langsmith.metadata.user_id': 'u_17', 'gen_ai.usage.input_tokens': 812, 'gen_ai.usage.output_tokens': 46, 'gen_ai.usage.total_tokens': 858}
tools_condition    parent=yes  {'langsmith.span.kind': 'chain', 'langsmith.metadata.user_id': 'u_17'}
tools              parent=yes  {'langsmith.span.kind': 'chain', 'langsmith.metadata.user_id': 'u_17'}
get_order_status   parent=yes  {'langsmith.span.kind': 'tool', 'langsmith.metadata.user_id': 'u_17'}
agent              parent=yes  {'langsmith.span.kind': 'chain', 'langsmith.metadata.user_id': 'u_17'}
ScriptedChatModel  parent=yes  {'langsmith.span.kind': 'llm', 'langsmith.metadata.user_id': 'u_17', 'gen_ai.usage.input_tokens': 905, 'gen_ai.usage.output_tokens': 61, 'gen_ai.usage.total_tokens': 966}
tools_condition    parent=yes  {'langsmith.span.kind': 'chain', 'langsmith.metadata.user_id': 'u_17'}
```

Two details from that run. My first version printed nothing at all, because a short script exits before the background thread hands the spans over; `wait_for_all_tracers()` fixed it. And the spans carry input, output and total tokens but not the cache read count, so keep the Step 5 cost line if your cost depends on caching.

Here is how the hosted options compared on their pricing pages on 15 October 2026:

| Option | Free tier | Paid entry | Overage | Retention |
| --- | --- | --- | --- | --- |
| LangSmith Developer | 5k base traces a month, 1 seat | $0 | Pay as you go | 14 days base |
| LangSmith Plus | 10k base traces a month | $39 per seat a month | 0.005 LSU ($0.005) per base trace | 14 days base, 180 days extended |
| Langfuse Cloud Hobby | 50k units a month, 2 users | $0 | None, upgrade required | 30 days |
| Langfuse Cloud Core | 100k units a month | $29 a month | $8 per 100k units | 90 days |
| Langfuse self hosted | Unlimited | $0, MIT license | Your infrastructure | You decide |

Sources: the [LangSmith pricing page](https://www.langchain.com/pricing), where the per trace rate sits in the tooltip beside "Pay as you go" and 1 LSU equals $1, and the [Langfuse pricing page](https://langfuse.com/pricing). Older posts quote a lower LangSmith rate per thousand traces, so check that tooltip before you budget. Langfuse counts [units](https://langfuse.com/docs/administration/billable-units) as traces plus observations plus scores, so one trace of a busy agent is many units.

## Troubleshooting

-   **`ValueError: No generations found in stream` in tests.** `GenericFakeChatModel` cannot stream a tool call message. Use `invoke` in that test, or a scripted model like the one in Step 2.
-   **A test loop ends one turn early.** Your test double is returning the same message object, so the reducer replaces it by id. Return a copy.
-   **`UsageMetadataCallbackHandler` returns `{}`.** The model integration is not reporting usage while streaming. Call the model directly with the handler to confirm, then turn on the provider's stream usage option.
-   **Usage keyed by an odd model name.** The handler groups by `response_metadata["model_name"]`. Map every name your providers return in the price table, or `run_cost` raises a `KeyError`. That is a loud failure on purpose.
-   **The `checkpoints` stream mode prints nothing.** It needs a checkpointer. Compile with one and pass a `thread_id`. My [Postgres checkpointer guide](https://www.jahanzaib.ai/blog/langgraph-checkpointer-postgres) covers the production setup.
-   **No spans reach your OpenTelemetry collector.** Install `langsmith[otel]`, set `LANGSMITH_TRACING=true`, and flush before the process exits. If you set the endpoint through the `OTEL_EXPORTER_OTLP_ENDPOINT` environment variable, give it the base URL: per the LangSmith guide, appending `/v1/traces` yourself produces a 404. The `endpoint` argument of `OTLPSpanExporter` works the other way and needs the full URL ending in `/v1/traces`.

## Going further

Price the tracing before you pick it. At 100,000 runs a month, LangSmith Plus with one seat comes to $39 plus 90,000 extra base traces at $0.005, so $489. My test run produced nine spans. If each becomes one Langfuse observation plus one trace, that is ten units a run and a million units a month: $29 plus 900,000 extra units at $8 per 100,000, so $101 on Core. Self hosting Langfuse costs whatever its servers cost you. Those are estimates from list prices and my span count, so run your own graph for a day and multiply.

When I set this up on an agent, I keep three layers: JSON logs and the cost line from Steps 4 and 5 always on, because they are free and live with the rest of the app's logs; a tracing backend, sampled down if volume makes it expensive; and alerts on the cost and recursion events, not on raw error counts, because a tool timing out once is noise while a loop is money. If you deploy with the LangGraph server, my [LangGraph CLI guide](https://www.jahanzaib.ai/blog/langgraph-cli) shows how to run it in Docker, and the same environment variables apply there.

When not to bother: a graph with no cycles and one model call does not need a recursion guard, and a prototype nobody else uses does not need a tracing bill. Add the log line and the cost line anyway.

[Video: LangSmith explained in 5 minutes](https://www.youtube.com/watch?v=KPcEhLoh-Wc)

_LangChain's own five minute tour. Tracing runs, traces and threads starts at 0:57, and monitoring in production at 3:44._

If you would rather have an agent built with this monitoring in place from day one, that is what I do: [see how I build AI agents](https://www.jahanzaib.ai/agents).

## Frequently asked questions

### What is the best way to monitor a LangGraph agent in production?

Use three layers. Log every node as structured JSON through a callback handler, record token usage and cost per run with `UsageMetadataCallbackHandler`, and send traces to LangSmith, Langfuse or an OpenTelemetry backend. Then alert on recursion limit errors and on runs that exceed a cost budget, because those two catch the failures that cost money.

### How do I track token usage in LangGraph?

Pass a fresh `UsageMetadataCallbackHandler` from langchain-core in the `callbacks` list of the run config. After the run, its `usage_metadata` attribute holds input, output and total tokens per model, including cache reads under `input_token_details`. Remember that `input_tokens` already includes the cached tokens when you compute cost.

### What is the default recursion limit in LangGraph?

On langgraph 1.2.15 the default is 10,007 steps, which I confirmed by running a looping agent until it raised `GraphRecursionError`. The docs say 1,000 and older guides say 25. You can change the default with the `LANGGRAPH_DEFAULT_RECURSION_LIMIT` environment variable, but setting `recursion_limit` in each run config is clearer.

### Can I use LangGraph without LangSmith?

Yes. LangGraph is open source and runs without any LangSmith account. You can log with Python's logging module, trace with Langfuse through its LangChain callback handler, or export OpenTelemetry spans to any collector by setting the LangSmith SDK to OpenTelemetry only mode, which sends nothing to LangSmith and needs no API key.

### Does LangGraph debug mode work in production?

It works, but I would not run it there. With a checkpointer, the `debug` stream mode emits the full state with every checkpoint event, which is heavy and can carry customer data into your logs. Use the `updates` mode for production event streams and keep `debug` for local work.

## Related

- [How to Run LangGraph Sessions for Many Users on One Checkpointer](https://www.jahanzaib.ai/blog/langgraph-session-management)
- [How to Use the LangGraph CLI to Build and Run an Agent Server in Docker](https://www.jahanzaib.ai/blog/langgraph-cli)
- [How to Use MCP Servers in LangGraph (and Serve a Graph as One)](https://www.jahanzaib.ai/blog/langgraph-mcp)

---

Canonical HTML version: https://www.jahanzaib.ai/blog/langgraph-monitoring-logging
