<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom"><channel><title>Langgraph on lo0 — Tech Blog</title><link>https://blog.lo0.es/en/tags/langgraph/</link><description>Recent content in Langgraph on lo0 — Tech Blog</description><generator>Hugo -- gohugo.io</generator><language>en</language><lastBuildDate>Tue, 15 Sep 2026 07:00:00 +0200</lastBuildDate><atom:link href="https://blog.lo0.es/en/tags/langgraph/index.xml" rel="self" type="application/rss+xml"/><item><title>deepagents on a cluster of your own: the SDK is free, the server is not, and what you have to build in between</title><link>https://blog.lo0.es/en/posts/deepagents-free-sdk-licensed-server/</link><pubDate>Tue, 15 Sep 2026 07:00:00 +0200</pubDate><guid>https://blog.lo0.es/en/posts/deepagents-free-sdk-licensed-server/</guid><description>&lt;blockquote>
&lt;p>Opening of the agentic platform vertical. The previous articles dealt with the pieces separately: &lt;a href="https://blog.lo0.es/en/posts/choosing-oss-gateway-llm-inference/">the inference gateway&lt;/a>, &lt;a href="https://blog.lo0.es/en/posts/litellm-mcp-gateway-second-front-door/">its second door&lt;/a>, &lt;a href="https://blog.lo0.es/en/posts/sizing-agents-decode-bottleneck/">what a fleet of agents costs&lt;/a> and &lt;a href="https://blog.lo0.es/en/posts/isolating-ai-agents-workstation-to-cluster/">isolation&lt;/a>. This one deals with what goes on top. Verified against &lt;code>deepagents&lt;/code> 0.7.14 and the LangGraph repository, commits of 14 September 2026.&lt;/p>
&lt;/blockquote>
&lt;h2 id="tldr">TL;DR&lt;/h2>
&lt;p>&lt;strong>deepagents is not a runtime.&lt;/strong> It is a factory that assembles middleware and delegates to LangChain&amp;rsquo;s agent factory. It returns a compiled LangGraph graph and nothing else. The repository&amp;rsquo;s own documentation says so without decoration: it introduces no new runtime. That is good news for whoever deploys it, because it runs wherever Python runs.&lt;/p>
&lt;p>&lt;strong>The planning tool no longer exists.&lt;/strong> It disappeared in 0.7.0 as a breaking change, along with the state channel that backed it. Almost everything written about this harness still describes it as one of its three legs. Today you have to ask for it explicitly, and you have to ask for it in each subagent too.&lt;/p>
&lt;p>&lt;strong>With the default configuration, the agent&amp;rsquo;s files live inside the checkpoint.&lt;/strong> The state backend stores the full content, binaries in base64, in the same place the conversation is persisted. Changing that is one line and it is the first architecture decision of the deployment.&lt;/p>
&lt;p>&lt;strong>The database grows per loop step, not per turn.&lt;/strong> Four tables, one row per superstep, one per channel and version, another per task write. And of the four cleanup operations the base class declares, the Postgres implementation has only one: delete an entire thread. The other three raise not-implemented.&lt;/p>
&lt;p>&lt;strong>Pruning by hand can empty the history silently.&lt;/strong> The code itself warns that a partial delete breaks the delta chain and leaves the channels rebuilding empty without raising any error. The only safe pruning is by whole thread.&lt;/p>
&lt;p>&lt;strong>Tenant isolation is the thread identifier, and there is no owner check.&lt;/strong> Whoever can send a thread identifier reads that thread. The on-disk filesystem backend fixes its root when the agent is built, so it separates nobody. And the documentation&amp;rsquo;s example for computing the per-user namespace does not work outside the managed platform.&lt;/p>
&lt;p>&lt;strong>There is a configuration trap that leaves a short-window model uncompacted.&lt;/strong> If the model does not expose its window, the compaction threshold falls back to 170,000 tokens. Served on a vLLM with a 32,768 window, the agent blows up on context length long before it compacts. The failure is silent and is fixed by passing the profile by hand.&lt;/p>
&lt;p>&lt;strong>Observability without the platform does exist, and it goes through a package that is not obvious.&lt;/strong> LangChain&amp;rsquo;s core does not emit OpenTelemetry: zero references. The exporter lives inside the LangSmith SDK, and there is a mode that emits over OTLP only, without talking to the service. It is the road to a Langfuse of your own, and it is worth knowing where it passes.&lt;/p>
&lt;p>&lt;strong>And here is where the free part ends: the server.&lt;/strong> The SDK, the graph engine and the checkpointers are MIT. The server that publishes them over HTTP carries an Elastic 2.0 licence, asks for a commercial licence key for production, adds Redis to the stack and reports metadata to an external endpoint unless there is an isolation agreement. The development tool is explicitly for development.&lt;/p>
&lt;p>&lt;strong>What is left is a list of things you have to write.&lt;/strong> Per-thread authorisation, retention, a run queue, cancellation, clean draining and migrations. None of them is hard. All of them are work, and none of them appears in the twenty-line front-page example.&lt;/p>
&lt;h2 id="you-are-here-the-harness-arrives-after-the-model">You are here: the harness arrives after the model&lt;/h2>
&lt;p>The sequence repeats on every project. First you set up inference, which is the visible part. Then the gateway, because you need keys and budgets. Then observability, because nobody knows what is going on. And when all of that works, somebody asks why the assistant cannot do multi-step tasks, and the word agent enters the scene.&lt;/p>
&lt;p>At that point the temptation is to write the loop by hand, and for two weeks it looks like a good idea. The loop is easy. What is not easy is everything you discover afterwards: what happens when the context fills up, where the intermediate files are stored, how you resume a task that was left half done, how you ask a human for confirmation without losing state.&lt;/p>
&lt;p>An agent harness is exactly that: the packaged answer to those questions. And choosing one looks like a library decision, about the size of choosing an HTTP client. It is not. It decides where your users&amp;rsquo; state lives, which database grows, what can be isolated and what cannot, and in the case at hand, whether there is a commercial licence waiting at the end of the road.&lt;/p>
&lt;p>This article looks at &lt;code>deepagents&lt;/code> with those eyes. It is not a usage guide, and I do not assess whether its agents are any good. I look at what it forces you to build.&lt;/p>
&lt;h2 id="the-analogy-the-scaffolding-and-the-crane">The analogy: the scaffolding and the crane&lt;/h2>
&lt;p>A building site needs scaffolding and it needs a crane, and they are not the same thing even though both are metal structures surrounding the building.&lt;/p>
&lt;p>You put up the scaffolding yourself with parts you bought. It adapts to the façade, it can be extended, and the day the work finishes you take it down and put it away. Nobody charges you for using it once it is bought.&lt;/p>
&lt;p>The crane is another matter. You hire it, it comes with an operator, it has a serial number and you have to notify somebody when it goes up. Without a crane the work still moves forward, more slowly and with more people, but it moves forward.&lt;/p>
&lt;p>Here the scaffolding is the SDK and the graph engine, and it belongs to whoever buys it. The crane is the server that exposes the agent over HTTP, and that one comes with a contract. This article is about where exactly the line between the two sits, because you cannot see it in the brochure, and about how much work can be done without a crane.&lt;/p>
&lt;h2 id="what-deepagents-actually-is">What deepagents actually is&lt;/h2>
&lt;p>The first thing to get out of your head is that it is a system. It is a function.&lt;/p>
&lt;p>The main factory assembles a list of middleware and calls LangChain&amp;rsquo;s agent factory, which is what builds the graph. What it returns is a compiled graph, with an added configuration on top that raises the recursion limit to a very high number and adds an integration tag in the metadata. The repository&amp;rsquo;s own architecture document states it: no new runtime is introduced.&lt;/p>
&lt;p>Compiled with the default options, the graph has the nodes you would expect from a reasoning-and-action loop: start, a prior node that repairs incomplete tool calls, the model node, the tools node and the end. The conditional edges run from the model to tools, to the model itself or to the end. There is nothing more. Everything that looks like a system is middleware that adds nodes only when it declares hooks of its own.&lt;/p>
&lt;p>The tools that ship by default are the filesystem ones, plus the subagent delegation one: list, read, write, edit, delete, search by pattern, search by content, execute, and the task tool.&lt;/p>
&lt;p>The good consequence of this is portability. If something is a LangGraph graph and nothing else, it runs in any Python container, with the checkpointer you choose, against the model you choose. There is no hidden service behind it.&lt;/p>
&lt;h3 id="what-is-no-longer-there">What is no longer there&lt;/h3>
&lt;p>It deserves its own section because it contradicts almost everything published. The planning tool, the one that wrote a task list and kept it in a state channel, &lt;strong>was removed in 0.7.0&lt;/strong> as a breaking change declared in the changelog. The tool is gone, the channel is gone, and the prompt fragment that went with it is gone.&lt;/p>
&lt;p>It survives as LangChain middleware, and you have to ask for it by hand. With one detail that bites: you have to ask for it in each subagent too, because each subagent assembles its own stack. All that remains from the previous stage are archaeological leftovers, such as the state key that is still on the list of keys excluded when passing context to a subagent.&lt;/p>
&lt;p>The plan, today, rests on files. Which is consistent with the rest of the design, and is another reason to look closely at where those files live.&lt;/p>
&lt;h2 id="where-state-lives">Where state lives&lt;/h2>
&lt;p>This is the first architecture decision and the one that is most expensive to get wrong.&lt;/p>
&lt;p>The agent&amp;rsquo;s state has two large channels. Messages, with a reducer of its own that deduplicates by identifier and treats deletions as tombstones. And files, with its own reducer where a null value means deleted. Both use a channel type that writes deltas and stores a full snapshot every fifty updates, and the code comment explains why: to take checkpoint growth from quadratic to linear with respect to the number of messages.&lt;/p>
&lt;p>That channel type is marked beta in its own code, with a warning that the on-disk representation may change. Worth knowing before you rest a platform on top of it.&lt;/p>
&lt;h3 id="the-backends">The backends&lt;/h3>
&lt;p>There is a common interface and several implementations. The ones that matter for deciding:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Backend&lt;/th>
&lt;th>Where the files live&lt;/th>
&lt;th>Persistence&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>State&lt;/td>
&lt;td>Inside the graph state, that is, inside the checkpoint&lt;/td>
&lt;td>Per thread, in the checkpointer&amp;rsquo;s database&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Filesystem&lt;/td>
&lt;td>In a real directory anchored to a root&lt;/td>
&lt;td>Wherever you mount the volume&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Store&lt;/td>
&lt;td>In LangGraph&amp;rsquo;s store, with a namespace&lt;/td>
&lt;td>Crosses threads, with a time to live&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Composite&lt;/td>
&lt;td>Routed by path prefix among the above&lt;/td>
&lt;td>Mixed&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>The default backend is the state one. That is: &lt;strong>by default, everything the agent writes ends up inside the checkpoint&lt;/strong>, and binaries go base64-encoded inside the same JSON. For a demo it is convenient, because you have nothing to set up. For a platform with real users it is a sizing decision taken by omission.&lt;/p>
&lt;p>There is a detail that rounds the matter off. The filesystem middleware automatically evicts large tool results to a file when they exceed a token threshold, so that they do not occupy the context window. It is a good mechanism. But with the state backend, evicting to a file means moving the content from one place in the checkpoint to another place in the same checkpoint. It leaves the model&amp;rsquo;s window and it does not leave the database.&lt;/p>
&lt;h3 id="what-grows-in-the-database">What grows in the database&lt;/h3>
&lt;p>The Postgres checkpointer creates four tables: one for migrations, one for checkpoints, one for blobs by channel and version, and one for writes by task. The primary keys all start with the thread identifier, and there is an index per table on that column.&lt;/p>
&lt;p>What you have to internalise is the rhythm. One checkpoint row is written &lt;strong>per engine superstep&lt;/strong>, not per conversation turn. A turn with four tool calls is several supersteps. None of that is recycled on its own.&lt;/p>
&lt;p>And here comes the uncomfortable part. The checkpointer&amp;rsquo;s base class declares four maintenance operations: delete a thread, delete by run, copy a thread and prune with a strategy. In the Postgres implementation &lt;strong>only the first is implemented&lt;/strong>. The other three inherit the not-implemented exception from the base class. Deleting a thread is three delete statements by identifier, and that is it.&lt;/p>
&lt;p>In other words: retention is yours to build. And with a care that the code itself documents and that is worth quoting in full, because it is the kind of warning you read after the incident. A naive pruning that removes intermediate checkpoints and their writes can cut the delta chain; the surviving checkpoint is rarely a snapshot point, so its channels would rebuild empty &lt;strong>without any error being raised&lt;/strong>.&lt;/p>
&lt;p>Translated into operations: the periodic sweep deletes whole threads and only whole threads. Never loose rows. And since the checkpoints table has no date column, to know which thread is old you have to keep count outside or rely on the checkpoint identifier being a time-sortable UUID.&lt;/p>
&lt;p>There is a variant of the checkpointer that stores only the last state and retains no history, which caps growth at the root. With one important reservation to verify before using it: it is not clear that it is compatible with the delta channels this harness uses by default for messages and files. Combining the two without checking is asking for a silent problem.&lt;/p>
&lt;h3 id="the-store-which-does-have-cleanup">The store, which does have cleanup&lt;/h3>
&lt;p>Curiously, the sibling piece is better resolved. The store has a configurable time to live, with refresh on read, skipping of expired entries and a sweep interval, and the sweep is a delete by expiry date.&lt;/p>
&lt;p>Two warnings. The sweeper is a thread inside the process, so with several replicas there are several sweepers competing for the same delete; it is idempotent, but it is free contention and you avoid it by switching it off and putting the delete in a scheduled cluster job. And the store&amp;rsquo;s semantic search requires the vector extension in the database, which constrains the managed Postgres image, which normally does not carry it.&lt;/p>
&lt;h2 id="the-isolation-that-does-not-exist">The isolation that does not exist&lt;/h2>
&lt;p>If the platform is going to have more than one tenant, this is the section that decides the deployment topology.&lt;/p>
&lt;p>The only thing separating one thread from another is the thread identifier. It is the first column of the primary key in all three tables. &lt;strong>There is no owner check anywhere in the checkpointer.&lt;/strong> Whoever manages to send a thread identifier in the call configuration reads that thread. Authorisation is the responsibility of the layer you write on top, and since that layer also has to be written, it is worth noting down now.&lt;/p>
&lt;p>By backend, the situation differs:&lt;/p>
&lt;ul>
&lt;li>The state one inherits thread isolation, which is enough if the HTTP layer validates that the user owns the thread.&lt;/li>
&lt;li>The store one computes the namespace per call, through a function that receives the runtime context. It is the correct option for multi-tenant.&lt;/li>
&lt;li>The filesystem one fixes its root &lt;strong>in the constructor&lt;/strong>, when the agent is built. There is no path in the code that recomputes it per request, per thread or per tenant. Sharing one deployment between tenants with this backend means everybody sees the same directory.&lt;/li>
&lt;/ul>
&lt;p>On that last point, do not confuse it with the protection that does exist. Virtual mode, which now comes enabled by default, blocks paths that escape the root. That protects against directory traversal. It does not protect against the neighbour. The project&amp;rsquo;s own threat document admits it: that mode exists to support the composite backend&amp;rsquo;s prefix routing, not as a security boundary.&lt;/p>
&lt;p>There is also a documentation trap worth flagging because it costs an afternoon. The example the documentation proposes for computing the per-user namespace reads the identity from a server-information field of the runtime context. That field is annotated in the LangGraph code as metadata injected by the managed server, and &lt;strong>null when running open-source LangGraph without managed deployments&lt;/strong>. On a cluster of your own with an HTTP layer of your own, that example fails. What you have to use is the context you fill in yourself, with the identifier that comes validated from the identity provider.&lt;/p>
&lt;p>And one more boundary that gets crossed without warning: the memory and skills middleware interpolate file content into the system prompt as is, without sanitising. If two tenants share a directory that serves as the source for that, the first writes instructions that the second&amp;rsquo;s agent executes. It is prompt injection through shared storage, and it is avoided with the same measure as everything above: do not share the filesystem backend between tenants.&lt;/p>
&lt;p>The topology conclusion is short. Real multi-tenant with files means &lt;strong>one deployment per tenant&lt;/strong>, with its namespace, its volume and its quota, or else the store backend with a namespace computed per request. There is no third way.&lt;/p>
&lt;h2 id="the-model-and-the-trap-you-need-to-know-about">The model, and the trap you need to know about&lt;/h2>
&lt;p>The default model is a specific commercial provider, and it is marked deprecated: passing an empty model warns and will stop working. You can inject any LangChain chat model, which is what anyone serving their own weights will do.&lt;/p>
&lt;p>With two integration details that are not obvious.&lt;/p>
&lt;p>The first. To point at an OpenAI-compatible endpoint, which is how a vLLM behind a gateway looks, you have to pass &lt;strong>the already-built instance&lt;/strong>, not the string with the provider prefix. With the string, a provider profile is applied that forces the use of the responses API, which a vLLM normally does not implement. Passing the built object applies no profile and the agent stays clean.&lt;/p>
&lt;p>The second is the trap, and in my view it is the most likely configuration failure in the whole deployment.&lt;/p>
&lt;p>The compaction middleware computes its thresholds from the model profile. If the model declares its input window, it compacts on reaching 85 % of the window and keeps 10 %. If it does not declare it, it falls back to a fixed value: &lt;strong>170,000 tokens&lt;/strong>.&lt;/p>
&lt;p>The chain that leads to not declaring it is the usual one in a deployment of your own. The OpenAI client resolves the profile by looking the model name up in a static table of the provider&amp;rsquo;s models. A model served as &lt;code>qwen3-30b-a3b&lt;/code> is not in that table, so the profile comes out empty, and empty becomes null. The method that assigns it swallows any exception, so there is no warning.&lt;/p>
&lt;p>Result: a vLLM with a 32,768-token window will not compact until 170,000. The server will return a context-length error long before that, and it will do so intermittently, only when conversations get long. There is a safety net that catches the error and trims, but that is reactive recovery after a failed call, not planning.&lt;/p>
&lt;p>The fix is one line, because the profile is a public field and only fills itself in when it is empty: you pass it when building the model, with a value below the real window to leave room for generation. And it deserves an &lt;code>assert&lt;/code> at start-up, because it is the kind of thing nobody looks at until it fails.&lt;/p>
&lt;p>A related sizing note. With the default thresholds and a 32,768 window, compaction would trigger at 27,852 tokens, while eviction of large tool results is fixed at 20,000. That is, a single tool result can occupy almost the whole budget before anything evicts it. With 128,000 windows the defaults are proportionate; below 64,000 you have to lower those two thresholds by hand.&lt;/p>
&lt;h2 id="the-tools-and-the-mcp-that-is-not-where-it-seems">The tools, and the MCP that is not where it seems&lt;/h2>
&lt;p>This harness has its filesystem tools and its delegation tool, and the rest are passed as an ordinary list of LangChain tools.&lt;/p>
&lt;p>The fact that matters to anyone with an MCP gateway in place: &lt;strong>the SDK does not integrate MCP&lt;/strong>. There is no reference to the protocol in the package, nor any dependency on adapters. The integration lives in the command-line tool that accompanies the project, which does depend on the MCP adapters for LangChain, resolves configurations in the style of the desktop clients, and turns each remote tool into a LangChain tool that then goes through the ordinary list.&lt;/p>
&lt;p>So the path exists and is proven, but you have to walk it yourself: load the gateway&amp;rsquo;s tools with the adapters and pass them in the list. Which, incidentally, fits well with &lt;a href="https://blog.lo0.es/en/posts/mcp-tool-priority-visibility-and-exclusion/">what we already know about the catalogue&lt;/a>: the credential-based filtering the gateway does is applied in the listing, so each agent receives the subset that belongs to it without the harness having to know anything.&lt;/p>
&lt;h3 id="the-subagents">The subagents&lt;/h3>
&lt;p>They are declared in three ways and invoked with a task tool that takes a description and a type. What you need to know to design with them:&lt;/p>
&lt;p>The subagent receives the parent&amp;rsquo;s state &lt;strong>except&lt;/strong> the messages and a few private keys, and it starts with a single human message which is the task description. Files &lt;strong>are&lt;/strong> inherited, and it shares the same backend object, so parent and child work on the same logical filesystem. The result comes back as a tool message with the structured response or the child&amp;rsquo;s last text.&lt;/p>
&lt;p>Depth is effectively one level. Verified by compiling the graph: the stack assembled for a declarative subagent does not include the subagent middleware, so the child does not have the task tool and cannot delegate in turn.&lt;/p>
&lt;p>And there is no concurrency limit of its own. The parallelism is that of the engine&amp;rsquo;s parallel tool calls, and the task tool&amp;rsquo;s prompt explicitly invites launching them in parallel. Anyone with a per-tenant GPU quota will want to put the brake where it belongs, because it is not here.&lt;/p>
&lt;h2 id="code-execution">Code execution&lt;/h2>
&lt;p>The sandbox protocol is well defined and the packaged implementations are nearly all paid services: four commercial providers, plus an embedded JavaScript interpreter and a sandbox from the platform vendor itself.&lt;/p>
&lt;p>That leaves a local shell backend that is worth looking at closely before considering it. It runs the command with the system shell, and its own docstring lists what it does not do: no isolation, no process separation, no resource limits, and it lists production and multi-tenant environments as inappropriate use cases. It also has an option to inherit the process environment which, enabled, hands the model every variable, including the gateway credentials and the database connection string. It comes disabled, and it is an easy foot to shoot.&lt;/p>
&lt;p>The reasonable way out on a cluster of your own is to implement the protocol yourself, and it is smaller than it looks. The sandbox base class requires four things: an identifier, execute, upload files and download files. Everything else, list, read, write, edit, delete and search, is built by the base class on top of execute. Looking at one of the commercial implementations, it is on the order of a hundred and fifty lines.&lt;/p>
&lt;p>One image requirement that constrains the Dockerfile and is worth knowing beforehand: the base class helpers inject small encoded Python scripts to resolve searches and checks, so the sandbox image &lt;strong>needs Python and a POSIX shell&lt;/strong> or half the calls fail.&lt;/p>
&lt;p>On how to implement it, the shape that fits a Kubernetes of your own is an ephemeral container per session with a bounded time to live, resource limits, no service account token mounted and a network policy that only allows egress towards the inference gateway. Execute is resolved against the cluster&amp;rsquo;s exec API, and upload and download through the same channel. The alternative with less code is an isolated runtime class for the agent pod, one pod per tenant, and the shell backend inside; you lose multi-tenancy in a single pod and you have nothing to write.&lt;/p>
&lt;h2 id="observability-and-the-package-it-goes-through">Observability, and the package it goes through&lt;/h2>
&lt;p>Here there is a fact that surprises and that is worth being clear about before designing the trace pipeline.&lt;/p>
&lt;p>&lt;strong>LangChain&amp;rsquo;s core does not emit OpenTelemetry.&lt;/strong> Zero references in the whole package. Its native tracer talks to the vendor&amp;rsquo;s observability service and that is that.&lt;/p>
&lt;p>The OTLP exporter does exist, but it lives inside that service&amp;rsquo;s SDK. And there is the good news: that SDK has a tracing mode that emits &lt;strong>only&lt;/strong> over OTLP, without talking to the service, reading the destination and headers from OpenTelemetry&amp;rsquo;s standard environment variables. The transport is HTTP with protobuf, which is exactly what a self-hosted Langfuse accepts on its ingestion route.&lt;/p>
&lt;p>That is: to take the agent&amp;rsquo;s traces to your own observability you have to install the vendor&amp;rsquo;s package and ask it not to talk to the vendor. It works, it is supported and it is documented, but it is a dependency you have to declare in the supply chain analysis, not a configuration detail. And if the OpenTelemetry packages are missing, it emits a warning and silently stops tracing, so start-up should check for it.&lt;/p>
&lt;p>There is the alternative of the callback handler that Langfuse itself publishes, with fewer pieces. The practical difference is correlation: over the OTLP route the agent&amp;rsquo;s spans share a trace identifier with the gateway&amp;rsquo;s and the inference engine&amp;rsquo;s, and you see a whole request end to end. Over the callback route, you do not. For anyone who already has a collector deployed, the OTLP route is the one that pays.&lt;/p>
&lt;h2 id="the-server-here-is-where-the-free-part-ends">The server: here is where the free part ends&lt;/h2>
&lt;p>Everything above is an architecture decision. This is a licence decision, and it is the one that gives the article its title.&lt;/p>
&lt;p>The harness SDK is MIT. The graph engine is MIT. The memory, SQLite and Postgres checkpointers are MIT. All of that runs anywhere, with no key, no registration and without calling anybody.&lt;/p>
&lt;p>The server that exposes a graph over HTTP is another matter. The package that implements it carries an &lt;strong>Elastic 2.0 licence&lt;/strong>, whose relevant clauses forbid offering the software to third parties as a managed service and forbid modifying or circumventing the licence key functionality. The command-line tool&amp;rsquo;s own command prints the conditions: for local development it asks for a service API key, and &lt;strong>for production use it asks for a licence key&lt;/strong> in an environment variable.&lt;/p>
&lt;p>There is also usage reporting. The server code contains a beacon endpoint to which it sends metadata, with a header carrying the licence key. The self-hosting documentation confirms it from the other side, listing among the requirements network egress to that domain for licence verification and usage reporting &lt;strong>if not running in isolated mode&lt;/strong>. Isolated mode is a contractual variant, not a box you tick.&lt;/p>
&lt;p>And it drags infrastructure along: besides Postgres, it asks for Redis.&lt;/p>
&lt;p>The development tool, which is the one that appears in every tutorial, describes itself as development mode with hot reload and an in-memory server. It is not a production server and it does not claim to be.&lt;/p>
&lt;p>None of this is a reproach. It is a legitimate and fairly common business model: permissive core, commercial control plane. What is not legitimate is finding out late. Anyone building a platform with a sovereignty requirement, with no dependencies on external services and with the argument that the whole stack is open, has to know that &lt;strong>the piece that turns the graph into a service does not meet that requirement&lt;/strong>.&lt;/p>
&lt;h2 id="what-you-have-to-build">What you have to build&lt;/h2>
&lt;p>The way out is to write the HTTP layer yourself, and the good news is that it is boring. A service that compiles the graph once with the checkpointer pointing at the database, and exposes an endpoint that validates the identity provider&amp;rsquo;s token, derives the thread identifier and the tenant context, and invokes the graph. Event streaming if needed.&lt;/p>
&lt;p>What you additionally have to write, and which the commercial server gave you ready-made:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Per-thread authorisation.&lt;/strong> The engine does not have it. It is the most important and the easiest to forget, because nothing fails if it is missing.&lt;/li>
&lt;li>&lt;strong>Retention and deletion.&lt;/strong> A scheduled job that deletes whole threads, never loose rows, for the reasons in the state section.&lt;/li>
&lt;li>&lt;strong>Migrations.&lt;/strong> Schema setup has to be called explicitly and needs data-definition permissions, so it goes in a separate job with a different role from the runtime one. And since it creates indexes concurrently, it cannot go through a connection pooler in transaction mode; point it at the direct write service.&lt;/li>
&lt;li>&lt;strong>Health probes and draining.&lt;/strong> The engine has a cooperative draining mechanism, but wiring it to the container&amp;rsquo;s termination signal is your own work.&lt;/li>
&lt;li>&lt;strong>Run queue and cancellation&lt;/strong>, if you need long background runs. This is where the absence is most felt, and the reasonable answer is a queue on the same database rather than another system.&lt;/li>
&lt;li>&lt;strong>Durability decided deliberately.&lt;/strong> There are three modes: persist before the next step, persist in parallel, or persist only on exit. The last one is no good on Kubernetes: if the pod dies, the whole turn is lost.&lt;/li>
&lt;/ul>
&lt;p>On that last point it is worth being explicit, because it affects tool design. The engine stores each task&amp;rsquo;s writes as soon as it finishes, and on resume it reapplies those that were already there and does not repeat them. A tool that finished is not run again. A tool &lt;strong>that was in flight when the pod died&lt;/strong> is, because its write never landed. The semantics are at-least-once, so every tool with an external effect has to be idempotent or carry an idempotency key. There is no exactly-once.&lt;/p>
&lt;p>And the same applies to interrupts for human confirmation: on resume, the node is &lt;strong>re-executed in full&lt;/strong>, so any side effect that sat before the interrupt inside the same node is repeated. The rule is that the interrupt goes first.&lt;/p>
&lt;h2 id="how-it-fits-with-activity-logging">How it fits with activity logging&lt;/h2>
&lt;p>Two things about this setup touch compliance head on, and both come out of earlier sections.&lt;/p>
&lt;p>The first is that the checkpoint is, de facto, a repository of personal data. It contains the whole conversation and, with the default backend, the files the agent has written. If the platform serves identifiable people, that database needs encryption, retention of its own and separate access control, exactly like the gateway&amp;rsquo;s spend table. The difference is that here there is no redaction switch that helps: the state is the state.&lt;/p>
&lt;p>The second is traceability. An agent that delegates to subagents and executes tools produces a chain of actions that you have to be able to reconstruct, and the only place where that chain is complete is the trace. Traces going out over OTLP to your own observability stops being a technical preference and becomes the logging mechanism. It is worth designing it that way from the start, and not adding it afterwards.&lt;/p>
&lt;p>As always in this series, the annex&amp;rsquo;s specific codes have to be checked against the current text before taking them into a compliance document; the approach is developed in &lt;a href="https://blog.lo0.es/en/posts/technical-controls-ens-iso-42001-eu-ai-act-cross-mapping/">the technical controls article&lt;/a>.&lt;/p>
&lt;h2 id="checklist">Checklist&lt;/h2>
&lt;ul>
&lt;li>Decide the filesystem backend before the first demo, because the default one puts the content in the database.&lt;/li>
&lt;li>Pass the model profile by hand with the real window, and check it at start-up.&lt;/li>
&lt;li>Lower the eviction thresholds if the model&amp;rsquo;s window is below 64,000 tokens.&lt;/li>
&lt;li>Build the model as an instance, never as a string with a provider prefix, if there is an OpenAI-compatible endpoint behind it.&lt;/li>
&lt;li>Write per-thread authorisation before opening the service to more than one user.&lt;/li>
&lt;li>One deployment per tenant if you use the filesystem backend; a namespace computed per request if you use the store one.&lt;/li>
&lt;li>Compute that namespace from your own context, not from the server-information field, which is null outside the managed platform.&lt;/li>
&lt;li>A scheduled retention job that deletes whole threads and only whole threads.&lt;/li>
&lt;li>Schema migrations in a separate job, with a role of their own and against the direct write service.&lt;/li>
&lt;li>Synchronous durability, and tools with external effects made idempotent.&lt;/li>
&lt;li>Traces over OTLP with the mode that does not talk to the vendor&amp;rsquo;s service, and a start-up check that the packages are there.&lt;/li>
&lt;li>Put a concurrency limit on subagents if there is a GPU quota involved.&lt;/li>
&lt;li>If you need code execution, implement the sandbox protocol against an ephemeral container; the local shell backend is advised against by its own documentation.&lt;/li>
&lt;li>Note in the supply chain analysis the hard dependencies on commercial providers that the package drags in even when unused.&lt;/li>
&lt;/ul>
&lt;h2 id="traps-and-things-that-are-not-what-they-look-like">Traps and things that are not what they look like&lt;/h2>
&lt;ul>
&lt;li>&lt;strong>The planning tool was removed in 0.7.0.&lt;/strong> You have to add it by hand, and in each subagent too.&lt;/li>
&lt;li>&lt;strong>The default backend stores files inside the checkpoint&lt;/strong>, binaries included in base64.&lt;/li>
&lt;li>&lt;strong>Evicting a large result to a file does not get it out of the database&lt;/strong> with that backend, only out of the context window.&lt;/li>
&lt;li>&lt;strong>One checkpoint is written per superstep&lt;/strong>, not per turn.&lt;/li>
&lt;li>&lt;strong>Of the four declared cleanup operations, only delete-a-thread exists in Postgres.&lt;/strong>&lt;/li>
&lt;li>&lt;strong>A partial pruning can empty the history without raising any error.&lt;/strong>&lt;/li>
&lt;li>&lt;strong>The delta channel is in beta&lt;/strong> and its on-disk representation may change.&lt;/li>
&lt;li>&lt;strong>There is no owner check in the checkpointer.&lt;/strong> The thread identifier is not a credential.&lt;/li>
&lt;li>&lt;strong>The filesystem backend&amp;rsquo;s root is fixed when the agent is built&lt;/strong> and it separates no tenants.&lt;/li>
&lt;li>&lt;strong>The documentation&amp;rsquo;s example for the per-user namespace does not work outside the managed platform.&lt;/strong>&lt;/li>
&lt;li>&lt;strong>The filesystem middleware and the subagent middleware cannot be removed.&lt;/strong>&lt;/li>
&lt;li>&lt;strong>If the model does not declare its window, compaction does not trigger until 170,000 tokens&lt;/strong>, silently.&lt;/li>
&lt;li>&lt;strong>Passing the model as a string with a provider prefix forces an API that a vLLM does not implement.&lt;/strong>&lt;/li>
&lt;li>&lt;strong>The SDK does not integrate MCP&lt;/strong>; the integration lives in the command-line tool.&lt;/li>
&lt;li>&lt;strong>Subagents do not delegate&lt;/strong>: depth is one level.&lt;/li>
&lt;li>&lt;strong>LangChain&amp;rsquo;s core does not emit OpenTelemetry&lt;/strong>; the exporter lives in the vendor&amp;rsquo;s service SDK.&lt;/li>
&lt;li>&lt;strong>The HTTP server carries an Elastic 2.0 licence&lt;/strong>, asks for a licence key for production and reports usage unless there is an isolation agreement.&lt;/li>
&lt;li>&lt;strong>The development tool is for development&lt;/strong>, with an in-memory server.&lt;/li>
&lt;li>&lt;strong>Resumption is at-least-once&lt;/strong> for in-flight tools.&lt;/li>
&lt;/ul>
&lt;h2 id="closing">Closing&lt;/h2>
&lt;p>The question this article started with was whether &lt;code>deepagents&lt;/code> fits into a platform of your own. The answer is that it does, and that it fits better than I expected, precisely because it is less than it appears. A middleware stack on top of a graph is easy to host, easy to understand and easy to replace. What does not fit is the piece next to it.&lt;/p>
&lt;p>That asymmetry is the pattern I have been seeing all year across the ecosystem, and it deserves naming. The core is published under a permissive licence because the core is where you compete for adoption. The control plane is published under a restrictive licence because the control plane is where you charge. It happened with the gateways, it is happening with observability, and it happens here. Anyone building a sovereign platform cannot assess a whole project by the licence of its main repository: you have to look package by package, and look at which of them is the one you are going to need the day the thing has users.&lt;/p>
&lt;p>The good part is that the bill, in this case, is paid in work and not in money. What you have to build to do without the commercial server is an HTTP service with no mystery to it, a retention job, a migration job and an authorisation layer. None of that is research. All of it is a week of an engineer who knows what they are doing, and in exchange you are left with a system that deploys the same way in a school as in a classified datacenter, with no internet egress and with nobody counting runs on the other side.&lt;/p>
&lt;p>What I do recommend is making that list before the first demo and not after. Because the front-page example works in twenty lines, and the twenty lines have the default backend, the model without a profile and no authorisation at all.&lt;/p>
&lt;h2 id="see-also">See also&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/choosing-oss-gateway-llm-inference/">Choosing an inference gateway&lt;/a> — the piece underneath, and the same licence pattern seen somewhere else.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/litellm-mcp-gateway-second-front-door/">LiteLLM&amp;rsquo;s MCP gateway&lt;/a> — where the tools this harness consumes come from.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/mcp-tool-priority-visibility-and-exclusion/">MCP tool priority&lt;/a> — how it is decided which subset of the catalogue each agent sees.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/sizing-agents-decode-bottleneck/">Sizing for agents&lt;/a> — the real load a tool loop generates on the gateway and the engine.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/isolating-ai-agents-workstation-to-cluster/">The contractor with the master key&lt;/a> — the network isolation this article takes as necessary.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/second-cost-vector-ai-agents-durable-execution-temporal/">The second cost vector of agents&lt;/a> — durable execution and what a loop that fails halfway costs.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/technical-controls-ens-iso-42001-eu-ai-act-cross-mapping/">Technical controls for ENS, 42001 and the AI Act&lt;/a> — the framework where logging and retention fit.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/litellm-langfuse-operational-pair/">LiteLLM and Langfuse: the operational pair&lt;/a> — the destination of the OTLP traces the article talks about.&lt;/li>
&lt;/ul>
&lt;h2 id="sources">Sources&lt;/h2>
&lt;ul>
&lt;li>&lt;code>langchain-ai/deepagents&lt;/code>, código y documento de arquitectura: &lt;a href="https://github.com/langchain-ai/deepagents">https://github.com/langchain-ai/deepagents&lt;/a>.&lt;/li>
&lt;li>&lt;code>langchain-ai/deepagents&lt;/code>, registro de cambios de la rama del SDK (eliminación de la herramienta de planificación en la 0.7.0): &lt;a href="https://github.com/langchain-ai/deepagents/blob/main/libs/deepagents/CHANGELOG.md">https://github.com/langchain-ai/deepagents/blob/main/libs/deepagents/CHANGELOG.md&lt;/a>.&lt;/li>
&lt;li>&lt;code>langchain-ai/langgraph&lt;/code>, motor, canales y checkpointers: &lt;a href="https://github.com/langchain-ai/langgraph">https://github.com/langchain-ai/langgraph&lt;/a>.&lt;/li>
&lt;li>&lt;code>langgraph-checkpoint-postgres&lt;/code>, esquema de tablas y operaciones de mantenimiento: &lt;a href="https://github.com/langchain-ai/langgraph/tree/main/libs/checkpoint-postgres">https://github.com/langchain-ai/langgraph/tree/main/libs/checkpoint-postgres&lt;/a>.&lt;/li>
&lt;li>&lt;code>langgraph-checkpoint&lt;/code>, clase base del checkpointer y aviso sobre poda con canales de deltas: &lt;a href="https://github.com/langchain-ai/langgraph/tree/main/libs/checkpoint">https://github.com/langchain-ai/langgraph/tree/main/libs/checkpoint&lt;/a>.&lt;/li>
&lt;li>LangChain, despliegue de servidor autónomo (requisitos de licencia, Postgres, Redis y salida de red): &lt;a href="https://docs.langchain.com/langsmith/deploy-standalone-server">https://docs.langchain.com/langsmith/deploy-standalone-server&lt;/a>.&lt;/li>
&lt;li>LangChain, trazado con OpenTelemetry: &lt;a href="https://docs.langchain.com/langsmith/trace-with-opentelemetry">https://docs.langchain.com/langsmith/trace-with-opentelemetry&lt;/a>.&lt;/li>
&lt;li>Elastic License 2.0: &lt;a href="https://www.elastic.co/licensing/elastic-license">https://www.elastic.co/licensing/elastic-license&lt;/a>.&lt;/li>
&lt;li>Langfuse, integración nativa OpenTelemetry (ruta de ingesta y autenticación): &lt;a href="https://langfuse.com/integrations/native/opentelemetry">https://langfuse.com/integrations/native/opentelemetry&lt;/a>.&lt;/li>
&lt;li>Langfuse, integración con LangChain por retrollamadas: &lt;a href="https://langfuse.com/integrations/frameworks/langchain">https://langfuse.com/integrations/frameworks/langchain&lt;/a>.&lt;/li>
&lt;li>&lt;code>langchain-ai/helm&lt;/code>, chart del servidor: &lt;a href="https://github.com/langchain-ai/helm">https://github.com/langchain-ai/helm&lt;/a>.&lt;/li>
&lt;li>Boletín Oficial del Estado, Real Decreto 311/2022, Esquema Nacional de Seguridad: &lt;a href="https://www.boe.es/buscar/act.php?id=BOE-A-2022-7191">https://www.boe.es/buscar/act.php?id=BOE-A-2022-7191&lt;/a>.&lt;/li>
&lt;/ul></description></item><item><title>Langfuse v4, day 2 (3 of 10): two well-known agents instrumented, Open Deep Research and GPT Researcher</title><link>https://blog.lo0.es/en/posts/langfuse-v4-real-agents-odr-gptr/</link><pubDate>Mon, 14 Sep 2026 07:00:00 +0200</pubDate><guid>https://blog.lo0.es/en/posts/langfuse-v4-real-agents-odr-gptr/</guid><description>&lt;blockquote>
&lt;p>Third article in a series of ten about operating Langfuse v4 in production. The first walked through &lt;a href="https://blog.lo0.es/en/posts/langfuse-v4-what-goes-into-a-trace/">what goes into a trace&lt;/a>; the second measured &lt;a href="https://blog.lo0.es/en/posts/langfuse-v4-instrumenting-langgraph/">what an agent turn costs&lt;/a> over a minimal graph. This one applies the same to two agents people deploy. Code read on 14 September 2026: Open Deep Research at commit &lt;code>1b7d2e8&lt;/code> and GPT Researcher at &lt;code>6f99857&lt;/code>, with Langfuse 4.15.2 and LangGraph 1.2.11.&lt;/p>
&lt;/blockquote>
&lt;h2 id="tldr">TL;DR&lt;/h2>
&lt;p>&lt;strong>One Open Deep Research request with the default configuration is 1,416 observations and 8.1 MB.&lt;/strong> Measured over a faithful replica of its topology with five concurrent units, six supervisor iterations and ten react calls per researcher, which are its defaults. It all lands in a single trace. And it is a floor, because on the bench the search results are three hundred characters and real ones are much larger.&lt;/p>
&lt;p>&lt;strong>None of those 1,416 is of type AGENT.&lt;/strong> Its nine nodes are called &lt;code>clarify_with_user&lt;/code>, &lt;code>write_research_brief&lt;/code>, &lt;code>research_supervisor&lt;/code>, &lt;code>final_report_generation&lt;/code>, &lt;code>supervisor&lt;/code>, &lt;code>supervisor_tools&lt;/code>, &lt;code>researcher&lt;/code>, &lt;code>researcher_tools&lt;/code> and &lt;code>compress_research&lt;/code>. None contains the string the handler looks for.&lt;/p>
&lt;p>&lt;strong>The parallel fan-out does not break the trace, and the fear that it does is unfounded.&lt;/strong> Open Deep Research forwards the &lt;code>config&lt;/code> to its subgraphs and GPT Researcher passes a fresh one carrying only tags. I tested both patterns: one trace in both cases, because LangChain rebuilds the config from the context variable.&lt;/p>
&lt;p>&lt;strong>GPT Researcher is two products in one repository.&lt;/strong> The multi-agent mode is LangGraph and traces like any graph. The standard mode has no graph, and there every model call opens its own trace: eight calls, eight traces, measured.&lt;/p>
&lt;p>&lt;strong>Its human node does not use &lt;code>interrupt()&lt;/code>, it waits on a websocket.&lt;/strong> The node&amp;rsquo;s observation stays open while the person thinks, so its duration measures human latency. The 95th percentile of that graph means nothing unless that node is excluded.&lt;/p>
&lt;p>&lt;strong>Instrumenting both requires no change to their repositories.&lt;/strong> For Open Deep Research it is enough to import the compiled graph and pass the handler through &lt;code>config&lt;/code>. For GPT Researcher&amp;rsquo;s standard mode you have to wrap with the SDK, because there is nowhere to hook callbacks.&lt;/p>
&lt;h2 id="you-are-here-observe-day-2">You are here: OBSERVE, day 2&lt;/h2>
&lt;p>The previous article produced a formula over a toy graph: &lt;code>4 + 5n&lt;/code> observations per turn, with the byte volume growing with the square of the steps. A formula over a toy is good for understanding the mechanism and no good for sizing anything.&lt;/p>
&lt;p>This article applies it to two real agents, chosen because they are well known and because they break in different ways. The series grows to ten articles and the migration one moves along one place.&lt;/p>
&lt;h2 id="the-analogy-two-factories-one-audit">The analogy: two factories, one audit&lt;/h2>
&lt;p>A traceability audit in a factory that runs the whole process on its own lines is tedious but complete: every station leaves a record, and the auditor reconstructs the part from beginning to end. The volume of paper is enormous and that is the only problem.&lt;/p>
&lt;p>The second factory subcontracts half the process. The auditor sees material going into a building and finished product coming out, with times and quantities, and has nothing about what happens inside. There is little paper. The problem is not the paper either.&lt;/p>
&lt;p>Open Deep Research is the first factory and the problem is volume. GPT Researcher, in its standard mode, is the second, and the problem is that the trace does not carry what you need to know. Both are fixable, with different tools, and confusing them leads to buying disk when what is missing is instrumentation.&lt;/p>
&lt;h2 id="the-two-agents-and-why-these">The two agents, and why these&lt;/h2>
&lt;p>&lt;strong>Open Deep Research&lt;/strong> (&lt;code>langchain-ai/open_deep_research&lt;/code>, 12.7 k stars) is LangChain&amp;rsquo;s reference research agent. All its work goes through LangChain, so callback instrumentation sees it whole.&lt;/p>
&lt;p>&lt;strong>GPT Researcher&lt;/strong> (&lt;code>assafelovic/gpt-researcher&lt;/code>, 28.4 k stars) is the best-known open source research agent and it is not from LangChain. Its multi-agent mode uses LangGraph; its standard mode, which is what the API and the web interface run, does not.&lt;/p>
&lt;p>One is in-house and the other is not, which for a comparison is what matters.&lt;/p>
&lt;h2 id="open-deep-research-the-shape-of-the-graph">Open Deep Research: the shape of the graph&lt;/h2>
&lt;p>Three graphs, two of them nested as subgraphs (&lt;code>src/open_deep_research/deep_researcher.py&lt;/code>):&lt;/p>
&lt;ul>
&lt;li>The root graph, &lt;code>deep_researcher&lt;/code> (line 701), with four nodes: &lt;code>clarify_with_user&lt;/code>, &lt;code>write_research_brief&lt;/code>, &lt;code>research_supervisor&lt;/code> and &lt;code>final_report_generation&lt;/code>.&lt;/li>
&lt;li>The supervisor subgraph (line 353), with &lt;code>supervisor&lt;/code> and &lt;code>supervisor_tools&lt;/code>, iterating between them.&lt;/li>
&lt;li>The researcher subgraph (line 589), with &lt;code>researcher&lt;/code>, &lt;code>researcher_tools&lt;/code> and &lt;code>compress_research&lt;/code>, which is a classic react loop.&lt;/li>
&lt;/ul>
&lt;p>The multiplication lives in &lt;code>supervisor_tools&lt;/code>. When the supervisor decides to delegate, that node launches one researcher subgraph per unit and awaits them together (line 305):&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-python" data-lang="python">&lt;span class="line">&lt;span class="cl">&lt;span class="n">research_tasks&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="p">[&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">researcher_subgraph&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">ainvoke&lt;/span>&lt;span class="p">({&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="s2">&amp;#34;researcher_messages&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="p">[&lt;/span>&lt;span class="n">HumanMessage&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">content&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">tool_call&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;args&amp;#34;&lt;/span>&lt;span class="p">][&lt;/span>&lt;span class="s2">&amp;#34;research_topic&amp;#34;&lt;/span>&lt;span class="p">])],&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="s2">&amp;#34;research_topic&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="n">tool_call&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;args&amp;#34;&lt;/span>&lt;span class="p">][&lt;/span>&lt;span class="s2">&amp;#34;research_topic&amp;#34;&lt;/span>&lt;span class="p">]&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">},&lt;/span> &lt;span class="n">config&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">for&lt;/span> &lt;span class="n">tool_call&lt;/span> &lt;span class="ow">in&lt;/span> &lt;span class="n">allowed_conduct_research_calls&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="p">]&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">tool_results&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="k">await&lt;/span> &lt;span class="n">asyncio&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">gather&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="o">*&lt;/span>&lt;span class="n">research_tasks&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>The detail that decides whether the trace comes out whole is that &lt;code>config&lt;/code> at the end: the node receives LangGraph&amp;rsquo;s configuration and forwards it to the subgraphs, so the callbacks travel and every researcher hangs off the right place.&lt;/p>
&lt;p>The three ceilings in the default configuration (&lt;code>src/open_deep_research/configuration.py&lt;/code>) are &lt;code>max_concurrent_research_units = 5&lt;/code>, &lt;code>max_researcher_iterations = 6&lt;/code> and &lt;code>max_react_tool_calls = 10&lt;/code>. They multiply each other.&lt;/p>
&lt;h2 id="what-one-request-costs-measured">What one request costs, measured&lt;/h2>
&lt;p>I replicated that topology with fake models, the same technique as the previous article: three graphs, the same nesting, the same fan-out with &lt;code>asyncio.gather&lt;/code>, three-hundred-character search results and two-hundred-character model replies. No network and no GPU, so it reproduces anywhere.&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Units&lt;/th>
&lt;th>Iterations&lt;/th>
&lt;th>React calls&lt;/th>
&lt;th>Observations&lt;/th>
&lt;th>generation&lt;/th>
&lt;th>chain&lt;/th>
&lt;th>Attribute KB&lt;/th>
&lt;th>Largest observation&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>1&lt;/td>
&lt;td>1&lt;/td>
&lt;td>1&lt;/td>
&lt;td>26&lt;/td>
&lt;td>8&lt;/td>
&lt;td>18&lt;/td>
&lt;td>56.8 KB&lt;/td>
&lt;td>3.7 KB&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>2&lt;/td>
&lt;td>2&lt;/td>
&lt;td>3&lt;/td>
&lt;td>92&lt;/td>
&lt;td>26&lt;/td>
&lt;td>66&lt;/td>
&lt;td>257.3 KB&lt;/td>
&lt;td>4.6 KB&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>5&lt;/td>
&lt;td>6&lt;/td>
&lt;td>10&lt;/td>
&lt;td>&lt;strong>1,416&lt;/strong>&lt;/td>
&lt;td>370&lt;/td>
&lt;td>1,046&lt;/td>
&lt;td>&lt;strong>8,108 KB&lt;/strong>&lt;/td>
&lt;td>11.0 KB&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>The last row is the repository&amp;rsquo;s default configuration. One research request, one person pressing a button once, leaves &lt;strong>1,416 observations and eight megabytes&lt;/strong> in the observability backend. In a single trace, under a single identifier.&lt;/p>
&lt;p>Those eight megabytes are a floor, not a ceiling. On the bench each search result takes three hundred characters; a real Tavily result carrying page content takes between ten and a hundred times more, and that text enters the history and gets re-serialised in every later observation of the same researcher, which is the previous article&amp;rsquo;s quadratic growth acting on much larger text.&lt;/p>
&lt;p>Put into capacity arithmetic: a hundred research requests a day, which for a small team is nothing, is 141,600 observations and on the order of a gigabyte a day uncompressed, with toy searches. With real searches, tens of gigabytes. It is another order of magnitude compared with a chat assistant, and it is the reason this article comes before the capacity one.&lt;/p>
&lt;h2 id="zero-agent-observations">Zero AGENT observations&lt;/h2>
&lt;p>Among those 1,416 observations there is not one of type &lt;code>AGENT&lt;/code>. The previous article explained why: the handler decides that type by looking for the string &lt;code>agent&lt;/code> in the class path or the run name. Open Deep Research&amp;rsquo;s nine nodes are &lt;code>clarify_with_user&lt;/code>, &lt;code>write_research_brief&lt;/code>, &lt;code>research_supervisor&lt;/code>, &lt;code>final_report_generation&lt;/code>, &lt;code>supervisor&lt;/code>, &lt;code>supervisor_tools&lt;/code>, &lt;code>researcher&lt;/code>, &lt;code>researcher_tools&lt;/code> and &lt;code>compress_research&lt;/code>.&lt;/p>
&lt;p>They are good names. They describe what each node does and they leave out a word that adds nothing for whoever reads the code. The result is that LangChain&amp;rsquo;s reference research agent, seen from Langfuse, is a tree of chains.&lt;/p>
&lt;p>It is fixable from outside, without touching the repository, by naming the subgraph invocation or renaming the two nodes that represent a model decision, which are &lt;code>supervisor&lt;/code> and &lt;code>researcher&lt;/code>. Without that, any dashboard grouping by observation type will not tell decision work apart from plumbing.&lt;/p>
&lt;h2 id="instrumenting-it-without-touching-the-repository">Instrumenting it without touching the repository&lt;/h2>
&lt;p>Open Deep Research ships as a LangGraph Server graph, with its &lt;code>langgraph.json&lt;/code> pointing at &lt;code>deep_researcher&lt;/code>. There are two ways to get the handler in, depending on how you run it.&lt;/p>
&lt;p>If you invoke it from your own code, the compiled graph is importable and the &lt;code>config&lt;/code> is enough:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-python" data-lang="python">&lt;span class="line">&lt;span class="cl">&lt;span class="kn">from&lt;/span> &lt;span class="nn">langfuse&lt;/span> &lt;span class="kn">import&lt;/span> &lt;span class="n">Langfuse&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">propagate_attributes&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="kn">from&lt;/span> &lt;span class="nn">langfuse.langchain&lt;/span> &lt;span class="kn">import&lt;/span> &lt;span class="n">CallbackHandler&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="kn">from&lt;/span> &lt;span class="nn">open_deep_research.deep_researcher&lt;/span> &lt;span class="kn">import&lt;/span> &lt;span class="n">deep_researcher&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">trace_id&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">Langfuse&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">create_trace_id&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">seed&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="sa">f&lt;/span>&lt;span class="s2">&amp;#34;&lt;/span>&lt;span class="si">{&lt;/span>&lt;span class="n">request_id&lt;/span>&lt;span class="si">}&lt;/span>&lt;span class="s2">&amp;#34;&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">handler&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">CallbackHandler&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">trace_context&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="p">{&lt;/span>&lt;span class="s2">&amp;#34;trace_id&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="n">trace_id&lt;/span>&lt;span class="p">})&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="k">with&lt;/span> &lt;span class="n">propagate_attributes&lt;/span>&lt;span class="p">(&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">trace_name&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;deep-research&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">user_id&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">user&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">session_id&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">request_id&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">tags&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;odr&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s2">&amp;#34;research&amp;#34;&lt;/span>&lt;span class="p">],&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="p">):&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">result&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="k">await&lt;/span> &lt;span class="n">deep_researcher&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">ainvoke&lt;/span>&lt;span class="p">(&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">{&lt;/span>&lt;span class="s2">&amp;#34;messages&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="p">[&lt;/span>&lt;span class="n">HumanMessage&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">question&lt;/span>&lt;span class="p">)]},&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">config&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="s2">&amp;#34;callbacks&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="p">[&lt;/span>&lt;span class="n">handler&lt;/span>&lt;span class="p">],&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="s2">&amp;#34;configurable&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="s2">&amp;#34;thread_id&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="n">request_id&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="s2">&amp;#34;max_concurrent_research_units&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="mi">3&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="s2">&amp;#34;max_researcher_iterations&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="mi">4&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">},&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">},&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Lowering those two ceilings from 5 and 6 to 3 and 4 is not an observability recommendation, it is a product decision that also divides the trace volume by three. Better taken at the same time.&lt;/p>
&lt;p>If you deploy it on LangGraph Server there is no invocation of yours to put the &lt;code>config&lt;/code> into, so the handler gets bound at compile time. The pattern is to wrap the exported graph:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-python" data-lang="python">&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># instrumented_graph.py, and point langgraph.json here&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="kn">from&lt;/span> &lt;span class="nn">langfuse.langchain&lt;/span> &lt;span class="kn">import&lt;/span> &lt;span class="n">CallbackHandler&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="kn">from&lt;/span> &lt;span class="nn">open_deep_research.deep_researcher&lt;/span> &lt;span class="kn">import&lt;/span> &lt;span class="n">deep_researcher&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">graph&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">deep_researcher&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">with_config&lt;/span>&lt;span class="p">({&lt;/span>&lt;span class="s2">&amp;#34;callbacks&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="p">[&lt;/span>&lt;span class="n">CallbackHandler&lt;/span>&lt;span class="p">()]})&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>That route loses the per-request seeded trace identifier, because the handler is created once. If the agent uses interrupts, that loss matters, for the reason the previous article measured.&lt;/p>
&lt;h2 id="gpt-researcher-two-products-in-one-repository">GPT Researcher: two products in one repository&lt;/h2>
&lt;p>Here is the interesting part, and it is a trap you fall into with any agent that has grown in layers.&lt;/p>
&lt;p>The repository holds the &lt;code>gpt_researcher&lt;/code> package, which is the engine, and the &lt;code>multi_agents&lt;/code> package, which is an orchestration on top. Only the second uses LangGraph.&lt;/p>
&lt;p>The orchestrator (&lt;code>multi_agents/agents/orchestrator.py:66&lt;/code>) has eight nodes: &lt;code>browser&lt;/code>, &lt;code>planner&lt;/code>, &lt;code>human&lt;/code>, &lt;code>researcher&lt;/code>, &lt;code>writer&lt;/code>, &lt;code>fact_checker&lt;/code>, &lt;code>visualizer&lt;/code> and &lt;code>publisher&lt;/code>, with two conditional loops, one for human review of the plan and one for fact checking. The &lt;code>researcher&lt;/code> node launches in parallel one editor subgraph per section (&lt;code>multi_agents/agents/editor.py:134&lt;/code>), and that subgraph has its own revision loop between &lt;code>reviewer&lt;/code> and &lt;code>reviser&lt;/code>.&lt;/p>
&lt;p>All of that traces like any graph. And the models are called with &lt;code>self.llm.ainvoke(messages, **kwargs)&lt;/code> from the generic provider (&lt;code>gpt_researcher/llm_provider/generic/base.py:365&lt;/code>), which is a LangChain chat model with no explicit config, so it picks up the ambient configuration while running inside a node. The generations show up.&lt;/p>
&lt;p>The standard mode is another thing. The &lt;code>GPTResearcher&lt;/code> class has no graph, no nodes and does not go through LangGraph at any point. It is the mode the API and the web interface use, that is, the one most people run.&lt;/p>
&lt;h2 id="the-myth-of-the-fresh-config">The myth of the fresh config&lt;/h2>
&lt;p>Before going on, something that looks like a bug and is not has to be dismantled, because it shows up in many agents.&lt;/p>
&lt;p>When the &lt;code>researcher&lt;/code> node launches the section subgraphs, it does it like this (&lt;code>multi_agents/agents/editor.py:74&lt;/code>):&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-python" data-lang="python">&lt;span class="line">&lt;span class="cl">&lt;span class="n">final_drafts&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="p">[&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">chain&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">ainvoke&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="bp">self&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">_create_task_input&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">research_state&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">query&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">title&lt;/span>&lt;span class="p">),&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">config&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="p">{&lt;/span>&lt;span class="s2">&amp;#34;tags&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;gpt-researcher&amp;#34;&lt;/span>&lt;span class="p">]})&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">for&lt;/span> &lt;span class="n">query&lt;/span> &lt;span class="ow">in&lt;/span> &lt;span class="n">queries&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="p">]&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">research_results&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="p">[&lt;/span>&lt;span class="n">result&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;draft&amp;#34;&lt;/span>&lt;span class="p">]&lt;/span> &lt;span class="k">for&lt;/span> &lt;span class="n">result&lt;/span> &lt;span class="ow">in&lt;/span> &lt;span class="k">await&lt;/span> &lt;span class="n">asyncio&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">gather&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="o">*&lt;/span>&lt;span class="n">final_drafts&lt;/span>&lt;span class="p">)]&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>That &lt;code>config&lt;/code> is fresh and carries only tags. The method does not even receive the node&amp;rsquo;s configuration, so it could not forward it. The intuitive conclusion is that the subgraphs lose the callbacks and every section ends up in its own trace.&lt;/p>
&lt;p>It does not happen. I set both patterns side by side, Open Deep Research&amp;rsquo;s forwarding the &lt;code>config&lt;/code> and GPT Researcher&amp;rsquo;s passing a fresh one, and both give &lt;strong>one trace with one root span&lt;/strong>. The reason is that LangChain does not take the config you pass as is: it starts from the ambient configuration held in a context variable and overlays the keys you bring. The callbacks are still there, and context variables are copied into the tasks &lt;code>asyncio.gather&lt;/code> creates.&lt;/p>
&lt;p>What does get lost are the parent&amp;rsquo;s tags, replaced by the new list. That is a filtering nuisance, not a broken trace.&lt;/p>
&lt;h2 id="the-standard-mode-eight-calls-eight-traces">The standard mode: eight calls, eight traces&lt;/h2>
&lt;p>With no graph there is no root run, and with no root run every model call is its own tree. Measured with eight standalone calls to a chat model, with the handler passed by hand on each one:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>&lt;/th>
&lt;th>&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Observations&lt;/td>
&lt;td>8&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Distinct traces&lt;/td>
&lt;td>&lt;strong>8&lt;/strong>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Root spans&lt;/td>
&lt;td>8&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Types&lt;/td>
&lt;td>8 generation&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>Eight traces of one observation each, with no relation between them. None of them knows it belongs to the same piece of research. And that is the favourable case, because the standard mode offers no public place to put that handler: the &lt;code>**kwargs&lt;/code> reach &lt;code>ainvoke&lt;/code> from &lt;code>create_chat_completion&lt;/code> (&lt;code>gpt_researcher/utils/llm.py:120&lt;/code>), but the path crosses several internal layers that are not meant as an extension point.&lt;/p>
&lt;p>The route that works without patching the repository is to wrap from outside with the SDK and let the inner calls hang off that:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-python" data-lang="python">&lt;span class="line">&lt;span class="cl">&lt;span class="kn">from&lt;/span> &lt;span class="nn">langfuse&lt;/span> &lt;span class="kn">import&lt;/span> &lt;span class="n">get_client&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">observe&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">propagate_attributes&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="kn">from&lt;/span> &lt;span class="nn">gpt_researcher&lt;/span> &lt;span class="kn">import&lt;/span> &lt;span class="n">GPTResearcher&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="nd">@observe&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">name&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;gpt-researcher&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">as_type&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;agent&amp;#34;&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="k">async&lt;/span> &lt;span class="k">def&lt;/span> &lt;span class="nf">research&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">question&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="nb">str&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">request_id&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="nb">str&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">user&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="nb">str&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="o">-&amp;gt;&lt;/span> &lt;span class="nb">str&lt;/span>&lt;span class="p">:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">with&lt;/span> &lt;span class="n">propagate_attributes&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">user_id&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">user&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">session_id&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">request_id&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">tags&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;gptr&amp;#34;&lt;/span>&lt;span class="p">]):&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">researcher&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">GPTResearcher&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">query&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">question&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">report_type&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;research_report&amp;#34;&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">await&lt;/span> &lt;span class="n">researcher&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">conduct_research&lt;/span>&lt;span class="p">()&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">report&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="k">await&lt;/span> &lt;span class="n">researcher&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">write_report&lt;/span>&lt;span class="p">()&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">client&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">get_client&lt;/span>&lt;span class="p">()&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">client&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">update_current_span&lt;/span>&lt;span class="p">(&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">output&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="p">{&lt;/span>&lt;span class="s2">&amp;#34;length&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="nb">len&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">report&lt;/span>&lt;span class="p">),&lt;/span> &lt;span class="s2">&amp;#34;sources&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="nb">len&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">researcher&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">get_source_urls&lt;/span>&lt;span class="p">())},&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">metadata&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="p">{&lt;/span>&lt;span class="s2">&amp;#34;gptr_costs&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="n">researcher&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">get_costs&lt;/span>&lt;span class="p">()},&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="n">report&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>With that there is one trace per piece of research, with an &lt;code>agent&lt;/code> observation at the root and the generations hanging inside, in the same tree. Searches and document chunking do not appear, because they do not go through LangChain. If they are needed, they get added with &lt;code>start_as_current_observation(as_type=&amp;quot;tool&amp;quot;)&lt;/code> around your own calls, which is the first of the three ways out of the span filter the previous article described.&lt;/p>
&lt;p>Worth noting &lt;code>researcher.get_costs()&lt;/code>. GPT Researcher keeps its own cost accounting, and putting it in as metadata is the only thing that lets you reconcile it later with what Langfuse computes. When the two figures disagree, it is almost always the gateway&amp;rsquo;s model alias.&lt;/p>
&lt;h2 id="the-human-node-that-stays-open">The human node that stays open&lt;/h2>
&lt;p>The multi-agent mode has a &lt;code>human&lt;/code> node that asks for an opinion on the plan before researching. It does not use LangGraph&amp;rsquo;s &lt;code>interrupt()&lt;/code>. It waits inside the node, reading from the websocket (&lt;code>multi_agents/agents/human.py&lt;/code>).&lt;/p>
&lt;p>The two approaches have opposite consequences for the trace. With &lt;code>interrupt()&lt;/code>, the graph hands control back and the trace splits in two, which is what the previous article measured. Waiting inside the node, the trace is single but that node&amp;rsquo;s observation stays open for as long as the person takes, minutes or hours.&lt;/p>
&lt;p>Neither is wrong. What is wrong is the dashboard that averages durations without knowing which of the two it has in front of it. With the wait inside the node, the 95th percentile of graph duration measures how long a human takes to answer a message, not how long the agent takes. Alert on that metric and the alert fires at lunchtime.&lt;/p>
&lt;p>The practical rule is to exclude human-wait nodes by name from any latency metric, and to measure that wait separately, which tends to be an interesting product metric in its own right.&lt;/p>
&lt;h2 id="what-to-look-at-in-the-trace-once-it-arrives">What to look at in the trace once it arrives&lt;/h2>
&lt;p>With both agents instrumented, and before building any dashboard, there are four checks that are done once and save weeks:&lt;/p>
&lt;ol>
&lt;li>&lt;strong>Count the observations of a typical request.&lt;/strong> If it comes out above a thousand, this platform&amp;rsquo;s problem is going to be volume and sampling has to be decided before anything else.&lt;/li>
&lt;li>&lt;strong>Look for more than one root span.&lt;/strong> More than one means there is work being traced outside the tree, and you want to know which before there are thousands.&lt;/li>
&lt;li>&lt;strong>Check that generations carry cost.&lt;/strong> If they come in at zero, it is the model alias, fixed by registering that name with its price.&lt;/li>
&lt;li>&lt;strong>Sort observations by duration and remove the ones waiting on a person.&lt;/strong> What stays at the top is where the time really is.&lt;/li>
&lt;/ol>
&lt;h2 id="checklist">Checklist&lt;/h2>
&lt;ul>
&lt;li>The graph is invoked with a trace identifier seeded per request, not a random one.&lt;/li>
&lt;li>The agent&amp;rsquo;s concurrency and iteration ceilings are set deliberately, knowing they multiply trace volume.&lt;/li>
&lt;li>Nodes representing a model decision carry &lt;code>agent&lt;/code> in the name, or it has been accepted in writing that there will be no observations of that type.&lt;/li>
&lt;li>Work that does not go through LangChain is wrapped with the SDK, or it has been decided that it need not be visible.&lt;/li>
&lt;li>Human-wait nodes are identified and excluded from latency metrics.&lt;/li>
&lt;li>The agent&amp;rsquo;s own cost accounting, if it has one, goes in as metadata so it can be reconciled.&lt;/li>
&lt;li>There is a measured figure of observations per request, taken from your own installation and not from this article.&lt;/li>
&lt;/ul>
&lt;h2 id="traps">Traps&lt;/h2>
&lt;p>&lt;strong>A research agent&amp;rsquo;s defaults are demo values, not production ones.&lt;/strong> Five units times six iterations times ten react calls multiply, and whoever chose them was thinking about report quality, not about your ClickHouse.&lt;/p>
&lt;p>&lt;strong>One repository can hold two agents with two different instrumentation stories.&lt;/strong> Instrumenting the mode the README documents and not the one the API runs is a mistake with no symptom: the traces arrive, just of something else.&lt;/p>
&lt;p>&lt;strong>Passing a fresh config to a subgraph does not break the trace.&lt;/strong> It breaks the tags. The tree survives because the callbacks travel through a context variable.&lt;/p>
&lt;p>&lt;strong>A node waiting on a person contaminates any duration statistic.&lt;/strong> And not obviously, because the node finishes cleanly and flags no error.&lt;/p>
&lt;p>&lt;strong>Good node names and good observation classification are conflicting goals.&lt;/strong> The winner is whoever writes the agent&amp;rsquo;s code, who is normally not you.&lt;/p>
&lt;p>&lt;strong>Eight megabytes per request with three-hundred-character searches.&lt;/strong> With real results, the number your installation produces will not look like this article&amp;rsquo;s, and it will be larger.&lt;/p>
&lt;h2 id="the-series-the-ten-articles">The series: the ten articles&lt;/h2>
&lt;ol>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/langfuse-v4-what-goes-into-a-trace/">What goes into a trace&lt;/a>: version 4&amp;rsquo;s data model, limits, precedences, scores, masking and indexes.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/langfuse-v4-instrumenting-langgraph/">Putting LangGraph in front&lt;/a>: instrumenting an agentic platform and the measured cost of one turn.&lt;/li>
&lt;li>Two well-known agents instrumented (this article): Open Deep Research and GPT Researcher, with the measured figures of a real request.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/langfuse-v4-migrating-with-no-window/">Migrating from version 3 to 4 with no window&lt;/a>: the three write-mode steps, the resumable background migrations and where the rollback point of no return sits.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/langfuse-v4-worker-queues/">The worker queues&lt;/a>: the map of all thirty-nine, which pool to dedicate to each group, the per-queue switches, sharding and concurrency.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/clickhouse-capacity-langfuse-v4-bytes-per-observation/">Real ClickHouse capacity and cost&lt;/a>: how to measure bytes per observation with the system tables, the difference between the full table and the listings table, and the merge cost of full-text indexes.&lt;/li>
&lt;li>Retention, deletion and data protection: why a deletion does not free disk, the mask cleaner that ships disabled, the pending deletion queue and the S3 lifecycle that has to be implemented by hand.&lt;/li>
&lt;li>Backup and cross recovery: restore ordering across Postgres, ClickHouse and object storage, what each mismatch breaks, and how far event replay goes.&lt;/li>
&lt;li>Saturation runbook: what to alert on from the queue metrics, the stuck probes, draining through the readiness endpoint and the dead letter queue.&lt;/li>
&lt;li>Getting the data out: the blob storage integration to Parquet, batch exports and the metrics API, to build the data lake.&lt;/li>
&lt;/ol>
&lt;h2 id="see-also">See also&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/langfuse-v4-instrumenting-langgraph/">Putting LangGraph in front&lt;/a>: the formula and the quadratic growth applied here to two real agents.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/langfuse-v4-what-goes-into-a-trace/">Langfuse v4: what really goes into a trace&lt;/a>: the data model that explains the cost of volume.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/sizing-agents-decode-bottleneck/">Sizing the gateway for a fleet of agents&lt;/a>: the same problem seen from the gateway.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/finops-multi-tenancy-gpu-cluster-who-pays/">FinOps and GPU multi-tenancy with LiteLLM&lt;/a>: why cost comes out at zero with a gateway alias.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/second-cost-vector-ai-agents-durable-execution-temporal/">Durable execution and the cost of agents&lt;/a>: what happens when the agent outlives the process.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/isolating-ai-agents-workstation-to-cluster/">Isolating AI agents per customer&lt;/a>: the isolation around an agent that browses and downloads.&lt;/li>
&lt;/ul>
&lt;h2 id="sources">Sources&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://github.com/langchain-ai/open_deep_research">Open Deep Research&lt;/a> source, commit &lt;code>1b7d2e8&lt;/code>, read on 14 September 2026: &lt;code>src/open_deep_research/deep_researcher.py&lt;/code> and &lt;code>configuration.py&lt;/code>.&lt;/li>
&lt;li>&lt;a href="https://github.com/assafelovic/gpt-researcher">GPT Researcher&lt;/a> source, commit &lt;code>6f99857&lt;/code>, read on 14 September 2026: &lt;code>multi_agents/agents/orchestrator.py&lt;/code>, &lt;code>multi_agents/agents/editor.py&lt;/code>, &lt;code>multi_agents/agents/human.py&lt;/code>, &lt;code>gpt_researcher/llm_provider/generic/base.py&lt;/code> and &lt;code>gpt_researcher/utils/llm.py&lt;/code>.&lt;/li>
&lt;li>Own measurement of 14 September 2026 over replicas of both topologies, with LangGraph 1.2.11, langchain-core 1.6.3, Langfuse SDK 4.15.2, &lt;code>InMemorySpanExporter&lt;/code> and fake models, no network.&lt;/li>
&lt;li>GitHub stars for both repositories, checked on 14 September 2026.&lt;/li>
&lt;/ul></description></item><item><title>Langfuse v4, day 2 (2 of 10): putting LangGraph in front, and what one agent turn costs in observations</title><link>https://blog.lo0.es/en/posts/langfuse-v4-instrumenting-langgraph/</link><pubDate>Sun, 13 Sep 2026 07:00:00 +0200</pubDate><guid>https://blog.lo0.es/en/posts/langfuse-v4-instrumenting-langgraph/</guid><description>&lt;blockquote>
&lt;p>Second article in a series of ten about operating Langfuse v4 in production. The first one, &lt;a href="https://blog.lo0.es/en/posts/langfuse-v4-what-goes-into-a-trace/">what really goes into a trace&lt;/a>, walked through the data model by reading the server code. This one measures the other side. Verified against Langfuse 4.15.2 (Python SDK), LangGraph 1.2.11 and langchain-core 1.6.3, with figures taken on 13 September 2026 on a bench with no network.&lt;/p>
&lt;/blockquote>
&lt;h2 id="tldr">TL;DR&lt;/h2>
&lt;p>&lt;strong>One agent turn is four observations plus five per tool call.&lt;/strong> Measured with an in-memory exporter over a minimal ReAct graph: zero tools, 4 observations; one, 9; five, 29; ten, 54; forty, 204. The formula holds exactly across the whole range.&lt;/p>
&lt;p>&lt;strong>The byte volume grows quadratically, not linearly.&lt;/strong> The same turn goes from 12.2 KB with one tool call to 1.87 MB with forty. 88 % of those bytes is the input field, because every observation re-serialises the full message history as it stands at that moment.&lt;/p>
&lt;p>&lt;strong>Tool definitions travel attached to every model call.&lt;/strong> The handler appends them to the input field as messages with role &lt;code>tool&lt;/code>. Measured: 364 bytes per tool. With twenty tools that is 7.3 KB on every single step of the loop.&lt;/p>
&lt;p>&lt;strong>The AGENT observation type is assigned by string match on the name.&lt;/strong> A node called &lt;code>planificador&lt;/code> comes out as &lt;code>CHAIN&lt;/code>; the same node called &lt;code>agente_planificador&lt;/code> comes out as &lt;code>AGENT&lt;/code>. The logic is literally looking for &lt;code>agent&lt;/code> in the class path or in the node name (&lt;code>langfuse/langchain/CallbackHandler.py:397&lt;/code>).&lt;/p>
&lt;p>&lt;strong>An interrupt with a resume produces two separate traces.&lt;/strong> Verified with &lt;code>invoke&lt;/code>, with &lt;code>stream&lt;/code> and with subgraphs, and also reusing the same handler object. The SDK ships machinery to reattach the trace, but it only fires if the root run finishes with a control-flow exception, and in LangGraph 1.2.11 the interrupt comes back through the return value.&lt;/p>
&lt;p>&lt;strong>The fix fits in one line and is measured.&lt;/strong> Passing &lt;code>trace_context={&amp;quot;trace_id&amp;quot;: Langfuse.create_trace_id(seed=thread_id)}&lt;/code> when building the handler leaves the interrupt and the resume in the same trace, even with different handlers and therefore with different replicas.&lt;/p>
&lt;p>&lt;strong>The span processor drops by default whatever it does not recognise.&lt;/strong> It only exports spans from the SDK&amp;rsquo;s own tracer, spans carrying some &lt;code>gen_ai.*&lt;/code> attribute, or spans from a closed list of thirty-five instrumentation prefixes (&lt;code>langfuse/_client/span_filter.py:11&lt;/code>). Your own code&amp;rsquo;s spans do not arrive unless they come through one of those three doors.&lt;/p>
&lt;h2 id="you-are-here-observe-day-2">You are here: OBSERVE, day 2&lt;/h2>
&lt;p>The series was planned with eight articles and has grown to ten. The reason is ordering: the next two are about migrating and about sizing ClickHouse, and both need an input figure that nobody publishes, which is how many observations and how many bytes a real unit of work produces. A chat produces a trace with two observations. An agent produces two hundred. Sizing with the chat figure leads to a cluster that falls over the day someone connects the agent.&lt;/p>
&lt;p>So before migrating, measure. And to measure there has to be in front what people actually deploy.&lt;/p>
&lt;h2 id="the-analogy-the-file-that-gets-photocopied-whole-at-every-signature">The analogy: the file that gets photocopied whole at every signature&lt;/h2>
&lt;p>A procurement file passes across six desks. At each desk somebody reads what is there, adds a sheet and passes it on.&lt;/p>
&lt;p>There are two ways to leave a record of that route. The first is to write in a register which desk touched it, when, and which sheet it added. Six short entries. The second is to photocopy the whole file on the way into each desk and on the way out, and to archive the twelve copies. The register takes one sheet. The copies take, at the sixth desk, six times what they took at the first, and in total they take something on the order of the square of the number of desks.&lt;/p>
&lt;p>LangChain&amp;rsquo;s callback instrumentation does the second thing. It does not record the delta of each step, it records the complete state on the way into and out of each step. With a chat that makes no difference, because the state is short and there is one step. With a forty-step agent, the archive weighs almost two megabytes per file processed.&lt;/p>
&lt;p>This is not a defect that needs fixing. It is what lets you open a trace and see exactly what the model saw at step 17 without reconstructing anything. But it drives the sizing, and the number has to be known before signing the contract.&lt;/p>
&lt;h2 id="which-agentic-platform-is-the-most-used">Which agentic platform is the most used&lt;/h2>
&lt;p>The question has two answers depending on the metric, and both are defensible.&lt;/p>
&lt;p>By PyPI downloads over the last thirty days, checked on 13 September 2026:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Package&lt;/th>
&lt;th>Downloads / 30 days&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>&lt;code>langgraph&lt;/code>&lt;/td>
&lt;td>52.3 M&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>strands-agents&lt;/code>&lt;/td>
&lt;td>35.2 M&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>openai-agents&lt;/code>&lt;/td>
&lt;td>22.9 M&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>crewai&lt;/code>&lt;/td>
&lt;td>19.1 M&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>google-adk&lt;/code>&lt;/td>
&lt;td>14.5 M&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>pydantic-ai&lt;/code>&lt;/td>
&lt;td>8.0 M&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>llama-index&lt;/code>&lt;/td>
&lt;td>5.0 M&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>agno&lt;/code>&lt;/td>
&lt;td>1.9 M&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>autogen-agentchat&lt;/code>&lt;/td>
&lt;td>0.6 M&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>By GitHub stars the order flips at the top: CrewAI is at 58.4 k, LangGraph at 38.1 k and the OpenAI Agents SDK at 28.6 k.&lt;/p>
&lt;p>PyPI downloads are inflated by continuous integration and by images that reinstall on every build, so they indicate presence in pipelines rather than agents in production. Stars measure interest, not deployment. With both together, LangGraph is the one that shows up in more pipelines and the one with the most worked-through instrumentation path in Langfuse, so it is the one instrumented here. What follows about volume applies equally to any platform instrumented through LangChain callbacks, because the cost comes from the callback model, not from the graph.&lt;/p>
&lt;h2 id="how-langgraph-gets-into-langfuse">How LangGraph gets into Langfuse&lt;/h2>
&lt;p>The route is worth stating, because the previous article left two open and only one is used here.&lt;/p>
&lt;p>&lt;code>langchain-core&lt;/code> 1.6.3 does not import OpenTelemetry anywhere. The check is direct against the installed wheel and returns not one file. LangGraph does not emit OTLP spans on its own either. What exists is LangChain&amp;rsquo;s callback system, and the Langfuse &lt;code>CallbackHandler&lt;/code> hooks into it.&lt;/p>
&lt;p>That handler does not talk to the classic ingestion API. It creates observations with the v4 SDK, which underneath is OpenTelemetry: &lt;code>LangfuseSpanProcessor&lt;/code> sets up an &lt;code>OTLPSpanExporter&lt;/code> against &lt;code>{base_url}/api/public/otel/v1/traces&lt;/code> (&lt;code>langfuse/_client/span_processor.py:123&lt;/code>). So it is OTLP, but generated by the SDK, not by third-party auto-instrumentation.&lt;/p>
&lt;p>The difference matters for one concrete reason left hanging in the previous article. Because the data passes through the SDK calls, the classic masking function does apply: input, output and metadata all go through &lt;code>_process_media_and_apply_mask&lt;/code> before becoming attributes (&lt;code>langfuse/_client/span.py:534&lt;/code>). With auto-instrumentation of the OpenInference or OpenLLMetry kind, the messages travel in span events and masking does not touch them. For an agent handling customer data inside its tools, that difference decides the route.&lt;/p>
&lt;p>The trade-off is that a callback handler only sees what goes through LangChain. HTTP calls a tool makes on its own, the database query, the &lt;code>subprocess&lt;/code>, none of that shows up. And if you instrument your code with OpenTelemetry so that it does show up, you run into the filter described further down.&lt;/p>
&lt;h2 id="the-observation-type-is-decided-by-the-node-name">The observation type is decided by the node name&lt;/h2>
&lt;p>The first article insisted that v4 has ten observation types and that using the right one changes what the interface knows how to group. The LangChain handler emits six of those ten: &lt;code>tool&lt;/code>, &lt;code>retriever&lt;/code>, &lt;code>generation&lt;/code>, &lt;code>agent&lt;/code>, &lt;code>chain&lt;/code> and &lt;code>span&lt;/code>. It never emits &lt;code>event&lt;/code>, &lt;code>embedding&lt;/code>, &lt;code>evaluator&lt;/code> or &lt;code>guardrail&lt;/code>.&lt;/p>
&lt;p>The assignment is direct for four of them: tool callback, type &lt;code>tool&lt;/code>; retriever callback, type &lt;code>retriever&lt;/code>; model callback, type &lt;code>generation&lt;/code>. For chain callbacks, which is what every node of a LangGraph graph is, the criterion is this (&lt;code>langfuse/langchain/CallbackHandler.py:393&lt;/code>):&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-python" data-lang="python">&lt;span class="line">&lt;span class="cl">&lt;span class="k">elif&lt;/span> &lt;span class="n">callback_type&lt;/span> &lt;span class="o">==&lt;/span> &lt;span class="s2">&amp;#34;chain&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="c1"># Detect if it&amp;#39;s an agent by examining class path or name&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">if&lt;/span> &lt;span class="n">serialized&lt;/span> &lt;span class="ow">and&lt;/span> &lt;span class="s2">&amp;#34;id&amp;#34;&lt;/span> &lt;span class="ow">in&lt;/span> &lt;span class="n">serialized&lt;/span>&lt;span class="p">:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">class_path&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">serialized&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;id&amp;#34;&lt;/span>&lt;span class="p">]&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">if&lt;/span> &lt;span class="nb">any&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;agent&amp;#34;&lt;/span> &lt;span class="ow">in&lt;/span> &lt;span class="n">part&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">lower&lt;/span>&lt;span class="p">()&lt;/span> &lt;span class="k">for&lt;/span> &lt;span class="n">part&lt;/span> &lt;span class="ow">in&lt;/span> &lt;span class="n">class_path&lt;/span>&lt;span class="p">):&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="s2">&amp;#34;agent&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">name&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="bp">self&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">get_langchain_run_name&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">serialized&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="o">**&lt;/span>&lt;span class="n">kwargs&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">if&lt;/span> &lt;span class="s2">&amp;#34;agent&amp;#34;&lt;/span> &lt;span class="ow">in&lt;/span> &lt;span class="n">name&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">lower&lt;/span>&lt;span class="p">():&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="s2">&amp;#34;agent&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="s2">&amp;#34;chain&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Measured over the same graph with the node renamed and everything else identical:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Node name&lt;/th>
&lt;th>Tool calls&lt;/th>
&lt;th>Observations&lt;/th>
&lt;th>Types&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>&lt;code>planificador&lt;/code>&lt;/td>
&lt;td>10&lt;/td>
&lt;td>54&lt;/td>
&lt;td>11 generation, 33 chain, 10 tool&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>agente_planificador&lt;/code>&lt;/td>
&lt;td>10&lt;/td>
&lt;td>54&lt;/td>
&lt;td>11 generation, 22 chain, 11 agent, 10 tool&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>Eleven observations change type because of eleven characters in a function name. This has two practical consequences. The first is that any dashboard counting &lt;code>AGENT&lt;/code> observations is counting naming conventions. The second is that the interface draws the agent graph when the trace contains any observation of a type other than &lt;code>span&lt;/code>, &lt;code>event&lt;/code> or &lt;code>generation&lt;/code>, and &lt;code>chain&lt;/code> qualifies, so the drawing comes out either way. What changes is what you can filter and aggregate afterwards.&lt;/p>
&lt;p>The operational recommendation is boring and it works: name the nodes that represent a model decision with a name containing &lt;code>agent&lt;/code>, and leave the rest as chains. It is a naming convention promoted to data schema, which is not pretty, but it is what there is and it is stable.&lt;/p>
&lt;h2 id="what-a-turn-costs-measured">What a turn costs, measured&lt;/h2>
&lt;p>The setup is a minimal ReAct graph: one node that calls the model, a &lt;code>ToolNode&lt;/code> with one tool, a conditional edge with &lt;code>tools_condition&lt;/code> and an in-memory checkpointer. The model is langchain-core&amp;rsquo;s &lt;code>GenericFakeChatModel&lt;/code>, which returns a fixed list of messages, so neither network nor GPU is needed and the experiment reproduces on any laptop. The exporter is OpenTelemetry&amp;rsquo;s &lt;code>InMemorySpanExporter&lt;/code>, passed to the client through the &lt;code>span_exporter&lt;/code> parameter, so observations are counted with no Langfuse server in front.&lt;/p>
&lt;p>Result, with the node named &lt;code>planificador&lt;/code>:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Tool calls&lt;/th>
&lt;th>Observations&lt;/th>
&lt;th>generation&lt;/th>
&lt;th>chain&lt;/th>
&lt;th>tool&lt;/th>
&lt;th>Attribute bytes&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>0&lt;/td>
&lt;td>4&lt;/td>
&lt;td>1&lt;/td>
&lt;td>3&lt;/td>
&lt;td>0&lt;/td>
&lt;td>4.2 KB&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>1&lt;/td>
&lt;td>9&lt;/td>
&lt;td>2&lt;/td>
&lt;td>6&lt;/td>
&lt;td>1&lt;/td>
&lt;td>12.2 KB&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>5&lt;/td>
&lt;td>29&lt;/td>
&lt;td>6&lt;/td>
&lt;td>18&lt;/td>
&lt;td>5&lt;/td>
&lt;td>64.1 KB&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>10&lt;/td>
&lt;td>54&lt;/td>
&lt;td>11&lt;/td>
&lt;td>33&lt;/td>
&lt;td>10&lt;/td>
&lt;td>173.5 KB&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>20&lt;/td>
&lt;td>104&lt;/td>
&lt;td>21&lt;/td>
&lt;td>63&lt;/td>
&lt;td>20&lt;/td>
&lt;td>541.4 KB&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>30&lt;/td>
&lt;td>154&lt;/td>
&lt;td>31&lt;/td>
&lt;td>93&lt;/td>
&lt;td>30&lt;/td>
&lt;td>1,108 KB&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>40&lt;/td>
&lt;td>204&lt;/td>
&lt;td>41&lt;/td>
&lt;td>123&lt;/td>
&lt;td>40&lt;/td>
&lt;td>1,874 KB&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>The observation count is exactly &lt;code>4 + 5n&lt;/code>. The five per loop iteration are: the model node, the model call, the conditional edge, the tools node and the tool. The four fixed ones are the root graph, the first partial pass and the close.&lt;/p>
&lt;p>The bytes do not follow that line. From 10 to 20 steps the volume multiplies by 3.1; from 20 to 40, by 3.5. That is the quadratic growth expected when each of the &lt;code>n&lt;/code> steps drags along a history whose length is proportional to &lt;code>n&lt;/code>.&lt;/p>
&lt;p>The breakdown by field confirms it. With forty steps, out of 1,709 KB measured in that run, 1,502 KB sit in &lt;code>langfuse.observation.input&lt;/code> and only 56 KB in the output. 88 % of what is ingested is the same history repeated at different stages of progress. The largest single observation in that turn is 22.2 KB, well below the 9.5 MB per OTLP span ceiling, so the problem is not the size limit: it is the aggregate.&lt;/p>
&lt;p>And inside the input there is a second multiplier. The handler appends each tool&amp;rsquo;s definition to the generation&amp;rsquo;s input field, as messages with role &lt;code>tool&lt;/code> (&lt;code>langfuse/langchain/CallbackHandler.py:1190&lt;/code>). Measured with three-parameter tools and a one-line description:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Bound tools&lt;/th>
&lt;th>Input field bytes of one generation&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>0&lt;/td>
&lt;td>34&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>1&lt;/td>
&lt;td>398&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>5&lt;/td>
&lt;td>1,854&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>10&lt;/td>
&lt;td>3,674&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>20&lt;/td>
&lt;td>7,334&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>That is 364 bytes per tool, on every generation, always. An agent with twenty tools and forty steps ingests 7.3 KB × 41 generations = 300 KB in tool schemas alone, which never change over the course of the turn.&lt;/p>
&lt;h2 id="what-this-means-for-clickhouse">What this means for ClickHouse&lt;/h2>
&lt;p>A honest caveat belongs here. I compressed the full payload of those runs with ZSTD level 3, which is what &lt;code>events_full&lt;/code> uses, and got ratios between 29× and 86×, improving the longer the turn. That number is inflated by how synthetic the bench is: my tool always returns the same string, so the redundancy is higher than in production.&lt;/p>
&lt;p>What does hold is the shape. Repeating the history is exactly the pattern columnar compression absorbs well, so the ClickHouse disk is not where it hurts. Where it hurts is earlier: in the bandwidth towards the OTLP endpoint, in the worker CPU that decompresses and flattens, and in the ingestion queue, all of which see uncompressed bytes. The first article noted that the queue that saturates first is &lt;code>langfuse.queue.ingestion.depth&lt;/code>, and this is why it saturates.&lt;/p>
&lt;p>Quick sizing arithmetic, with the measured figures and an average ten-step turn:&lt;/p>
&lt;ul>
&lt;li>1,000 turns a day is 54,000 observations and around 170 MB uncompressed a day.&lt;/li>
&lt;li>10,000 turns a day is 540,000 observations and 1.7 GB uncompressed a day.&lt;/li>
&lt;li>If the average turn goes from ten steps to twenty, observations double and bytes triple.&lt;/li>
&lt;/ul>
&lt;p>Sensitivity to the number of steps is the parameter to watch, more than the number of users. A prompt change that makes the agent take three more turns around the loop multiplies the observability bill with nobody touching the infrastructure. Article 6 of the series comes back to this with ClickHouse system tables to measure the real bytes per observation in your own installation, which is the only thing that replaces this estimate.&lt;/p>
&lt;h2 id="interrupts-two-traces-where-there-should-be-one">Interrupts: two traces where there should be one&lt;/h2>
&lt;p>A production agent with human approval uses &lt;code>interrupt()&lt;/code>, hands control back, waits for a person and resumes with &lt;code>Command(resume=...)&lt;/code>. The operational question is whether the trace survives that pause.&lt;/p>
&lt;p>The SDK ships explicit machinery for the case. There is a store of pending trace contexts keyed by &lt;code>thread_id&lt;/code>, capped at 1,024 entries with eviction of the oldest (&lt;code>MAX_PENDING_RESUME_TRACE_CONTEXTS&lt;/code>), and a function that recognises a &lt;code>Command&lt;/code> carrying &lt;code>resume&lt;/code> and recovers the stored context.&lt;/p>
&lt;p>Measured, it does not fire in this combination of versions:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Case&lt;/th>
&lt;th>Same trace?&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Same handler object, &lt;code>invoke&lt;/code>&lt;/td>
&lt;td>No&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Same handler object, &lt;code>stream&lt;/code>&lt;/td>
&lt;td>No&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Interrupt inside a subgraph&lt;/td>
&lt;td>No&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Fresh handler for the resume&lt;/td>
&lt;td>No&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>The reason is in the code and it is consistent. The pending context is only stored from &lt;code>on_chain_error&lt;/code> when the failing run is the root one and the resulting level is &lt;code>DEFAULT&lt;/code>, which is what happens with LangGraph&amp;rsquo;s control-flow exceptions (&lt;code>CallbackHandler.py:866&lt;/code>). In LangGraph 1.2.11 the interrupt does not leave the root as an exception: the graph returns normally, with the interrupt in the return value. The root run ends through &lt;code>on_chain_end&lt;/code> and the branch that stores the context never runs. The interrupted node&amp;rsquo;s observation does come out right: level &lt;code>DEFAULT&lt;/code> and the &lt;code>Interrupt&lt;/code> in the status message, not marked as an error, which is the correct behaviour.&lt;/p>
&lt;p>The fix is verified and does not depend on the SDK changing:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-python" data-lang="python">&lt;span class="line">&lt;span class="cl">&lt;span class="kn">from&lt;/span> &lt;span class="nn">langfuse&lt;/span> &lt;span class="kn">import&lt;/span> &lt;span class="n">Langfuse&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="kn">from&lt;/span> &lt;span class="nn">langfuse.langchain&lt;/span> &lt;span class="kn">import&lt;/span> &lt;span class="n">CallbackHandler&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">trace_id&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">Langfuse&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">create_trace_id&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">seed&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">thread_id&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">handler&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">CallbackHandler&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">trace_context&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="p">{&lt;/span>&lt;span class="s2">&amp;#34;trace_id&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="n">trace_id&lt;/span>&lt;span class="p">})&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>With that, the interrupt and the resume land in the same trace even if the handler is a different object and even if another replica serves the resume, because the identifier derives from the &lt;code>thread_id&lt;/code> and not from in-memory state. It is the same technique the documentation recommends for joining several agents into one trace, applied to joining an agent with itself.&lt;/p>
&lt;p>With one nuance to decide deliberately. Seeding with the bare &lt;code>thread_id&lt;/code> puts every turn of that conversation into a single trace, which grows without bound while the conversation stays alive. If what you want is one trace per turn with the resume attached to the right turn, seed with &lt;code>thread_id&lt;/code> plus the turn number, and leave conversation grouping to the session identifier, which is what it is for. Since v4 has no trace table, a huge trace is not a huge row: it is a filter returning many rows, and what suffers is the interface when opening it.&lt;/p>
&lt;h2 id="thread-session-user-and-what-is-only-read-at-the-root">Thread, session, user, and what is only read at the root&lt;/h2>
&lt;p>The handler recognises five metadata keys: &lt;code>langfuse_session_id&lt;/code>, &lt;code>langfuse_user_id&lt;/code>, &lt;code>langfuse_trace_name&lt;/code>, &lt;code>langfuse_tags&lt;/code> and &lt;code>langfuse_prompt&lt;/code>. The first four are read only when &lt;code>parent_run_id&lt;/code> is &lt;code>None&lt;/code>, that is, only on the root run (&lt;code>CallbackHandler.py:587&lt;/code>). Putting them in the config used to invoke a subgraph does nothing.&lt;/p>
&lt;p>The recommended route, and the one that works without surprises, is the context manager:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-python" data-lang="python">&lt;span class="line">&lt;span class="cl">&lt;span class="kn">from&lt;/span> &lt;span class="nn">langfuse&lt;/span> &lt;span class="kn">import&lt;/span> &lt;span class="n">propagate_attributes&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="k">with&lt;/span> &lt;span class="n">propagate_attributes&lt;/span>&lt;span class="p">(&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">trace_name&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;agent-turn&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">user_id&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">user&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">session_id&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">thread_id&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">tags&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;production&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s2">&amp;#34;support-l1&amp;#34;&lt;/span>&lt;span class="p">],&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="p">):&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">graph&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">invoke&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">payload&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">config&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">cfg&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>What LangGraph contributes on its own arrives anyway. In the measured run these show up as observation metadata: &lt;code>langgraph_node&lt;/code>, &lt;code>langgraph_step&lt;/code>, &lt;code>langgraph_triggers&lt;/code>, &lt;code>langgraph_path&lt;/code>, &lt;code>langgraph_checkpoint_ns&lt;/code>, &lt;code>checkpoint_ns&lt;/code> and &lt;code>thread_id&lt;/code>, plus &lt;code>ls_provider&lt;/code>, &lt;code>ls_model_type&lt;/code> and &lt;code>ls_integration&lt;/code>. The &lt;code>thread_id&lt;/code> is also promoted to trace metadata.&lt;/p>
&lt;p>That is fine, but keeping in mind the first article: in &lt;code>events_core&lt;/code> what is indexed is &lt;code>metadata_names&lt;/code>, that is the key names, not the values. Filtering by &lt;code>langgraph_node = &amp;quot;planificador&amp;quot;&lt;/code> is a scan. If you are going to segment by node routinely, the right place is the observation name, which is where the node already appears, or a tag, assuming that tags are not indexed either. The cheap field for slicing by node in v4 is the observation name itself.&lt;/p>
&lt;p>The session identifier deserves an explicit decision. LangGraph&amp;rsquo;s &lt;code>thread_id&lt;/code> and Langfuse&amp;rsquo;s &lt;code>session_id&lt;/code> are the same concept and they do not connect on their own. Mapping them one to one is the reasonable thing, and it is also what makes the whole conversation navigable in the interface even when each turn is a separate trace.&lt;/p>
&lt;h2 id="the-filter-that-drops-your-own-spans">The filter that drops your own spans&lt;/h2>
&lt;p>This is the finding that surprises most people while setting it up. The Langfuse span processor does not export everything that passes through the &lt;code>TracerProvider&lt;/code>. It exports what satisfies one of these three conditions (&lt;code>langfuse/_client/span_filter.py:104&lt;/code>):&lt;/p>
&lt;ol>
&lt;li>It was created by the Langfuse SDK&amp;rsquo;s own tracer.&lt;/li>
&lt;li>It carries at least one attribute starting with &lt;code>gen_ai&lt;/code>.&lt;/li>
&lt;li>Its instrumentation scope matches one of thirty-five prefixes in a closed list.&lt;/li>
&lt;/ol>
&lt;p>The list covers the expected and a bit more: &lt;code>openinference&lt;/code>, &lt;code>litellm&lt;/code>, &lt;code>haystack&lt;/code>, &lt;code>langsmith&lt;/code>, &lt;code>strands-agents&lt;/code>, &lt;code>pydantic-ai&lt;/code>, &lt;code>autogen-core&lt;/code>, &lt;code>vllm&lt;/code> and a couple of dozen &lt;code>opentelemetry.instrumentation.*&lt;/code> entries.&lt;/p>
&lt;p>The consequence for an agent is direct. If you instrument your tools with OpenTelemetry by hand so that the ERP call or the Postgres query shows up inside the agent&amp;rsquo;s trace, those spans are dropped silently, with no log and no error. There are three ways out, in order of cleanliness:&lt;/p>
&lt;ul>
&lt;li>Create those observations with the Langfuse SDK, with &lt;code>start_as_current_observation(as_type=&amp;quot;tool&amp;quot;)&lt;/code>, which is what the rest of this article assumes.&lt;/li>
&lt;li>Put some &lt;code>gen_ai.*&lt;/code> attribute on the span, which is abusing the convention to smuggle the span through, so only if the span does describe an interaction with a model.&lt;/li>
&lt;li>Send those spans to your normal OpenTelemetry collector instead of Langfuse, and correlate by trace identifier. For a sovereign platform with its own collector, this is the sensible option: Langfuse keeps the model side, the collector keeps the system side, and the trace identifier joins them.&lt;/li>
&lt;/ul>
&lt;p>The behaviour being silent is what makes this take an afternoon to find. It belongs in the runbook.&lt;/p>
&lt;h2 id="the-model-and-the-cost-with-a-gateway-in-the-middle">The model and the cost with a gateway in the middle&lt;/h2>
&lt;p>One detail that affects anyone putting LiteLLM in front, which is the setup described in &lt;a href="https://blog.lo0.es/en/posts/litellm-langfuse-operational-pair/">the operational pair&lt;/a> and in &lt;a href="https://blog.lo0.es/en/posts/finops-multi-tenancy-gpu-cluster-who-pays/">GPU multi-tenancy&lt;/a>.&lt;/p>
&lt;p>The model name the handler puts on the generation comes first from the &lt;code>ls_model_name&lt;/code> metadata and, failing that, from the component&amp;rsquo;s serialisation (&lt;code>CallbackHandler.py:1297&lt;/code>). With a &lt;code>ChatOpenAI&lt;/code> pointed at the gateway, that name is whatever alias you defined in LiteLLM, for example &lt;code>qwen-32b-internal&lt;/code>. Langfuse computes cost from its own model table, which does not know that alias, so the generation arrives with tokens and without cost.&lt;/p>
&lt;p>It is fixed by defining the model in Langfuse under that same name with its price per million tokens, which is also the only way for the cost to reflect what your GPU costs rather than what somebody else&amp;rsquo;s API costs. It is an afternoon of work and it stops the agentic platform&amp;rsquo;s cost panel from sitting at zero forever.&lt;/p>
&lt;h2 id="how-this-looks-in-code">How this looks in code&lt;/h2>
&lt;p>The minimum to have in place for a LangGraph agent to produce useful traces in Langfuse v4:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-python" data-lang="python">&lt;span class="line">&lt;span class="cl">&lt;span class="kn">import&lt;/span> &lt;span class="nn">os&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="kn">from&lt;/span> &lt;span class="nn">typing&lt;/span> &lt;span class="kn">import&lt;/span> &lt;span class="n">Annotated&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">TypedDict&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="kn">from&lt;/span> &lt;span class="nn">langfuse&lt;/span> &lt;span class="kn">import&lt;/span> &lt;span class="n">Langfuse&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">get_client&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">propagate_attributes&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="kn">from&lt;/span> &lt;span class="nn">langfuse.langchain&lt;/span> &lt;span class="kn">import&lt;/span> &lt;span class="n">CallbackHandler&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="kn">from&lt;/span> &lt;span class="nn">langchain_core.messages&lt;/span> &lt;span class="kn">import&lt;/span> &lt;span class="n">HumanMessage&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="kn">from&lt;/span> &lt;span class="nn">langgraph.graph&lt;/span> &lt;span class="kn">import&lt;/span> &lt;span class="n">StateGraph&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">START&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="kn">from&lt;/span> &lt;span class="nn">langgraph.graph.message&lt;/span> &lt;span class="kn">import&lt;/span> &lt;span class="n">add_messages&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="kn">from&lt;/span> &lt;span class="nn">langgraph.prebuilt&lt;/span> &lt;span class="kn">import&lt;/span> &lt;span class="n">ToolNode&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">tools_condition&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># 1. Client. Masking applies because the data goes through the SDK.&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="k">def&lt;/span> &lt;span class="nf">mask&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="o">*&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">data&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="o">**&lt;/span>&lt;span class="n">kwargs&lt;/span>&lt;span class="p">):&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="n">redact_pii&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">data&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">Langfuse&lt;/span>&lt;span class="p">(&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">public_key&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">os&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">environ&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;LANGFUSE_PUBLIC_KEY&amp;#34;&lt;/span>&lt;span class="p">],&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">secret_key&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">os&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">environ&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;LANGFUSE_SECRET_KEY&amp;#34;&lt;/span>&lt;span class="p">],&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">host&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">os&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">environ&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;LANGFUSE_HOST&amp;#34;&lt;/span>&lt;span class="p">],&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">mask&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">mask&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">environment&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;prod&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="c1"># lowercase, dashes, 40 chars, must not start with &amp;#34;langfuse&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">sample_rate&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="nb">float&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">os&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">getenv&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;LANGFUSE_SAMPLE_RATE&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s2">&amp;#34;1.0&amp;#34;&lt;/span>&lt;span class="p">)),&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># 2. Graph. The decision node carries &amp;#34;agent&amp;#34; in its name on purpose.&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="k">class&lt;/span> &lt;span class="nc">State&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">TypedDict&lt;/span>&lt;span class="p">):&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">messages&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="n">Annotated&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="nb">list&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">add_messages&lt;/span>&lt;span class="p">]&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">graph&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">StateGraph&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">State&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">graph&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">add_node&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;agent_planner&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">model_node&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">graph&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">add_node&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;tools&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">ToolNode&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">TOOLS&lt;/span>&lt;span class="p">))&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">graph&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">add_edge&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">START&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s2">&amp;#34;agent_planner&amp;#34;&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">graph&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">add_conditional_edges&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;agent_planner&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">tools_condition&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">graph&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">add_edge&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;tools&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s2">&amp;#34;agent_planner&amp;#34;&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">app&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">graph&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">compile&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">checkpointer&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">checkpointer&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># 3. Per turn: deterministic trace seeded with thread and turn, session = thread.&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="k">def&lt;/span> &lt;span class="nf">run_turn&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">thread_id&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="nb">str&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">turn&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="nb">int&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">text&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="nb">str&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">user&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="nb">str&lt;/span>&lt;span class="p">):&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">trace_id&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">Langfuse&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">create_trace_id&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">seed&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="sa">f&lt;/span>&lt;span class="s2">&amp;#34;&lt;/span>&lt;span class="si">{&lt;/span>&lt;span class="n">thread_id&lt;/span>&lt;span class="si">}&lt;/span>&lt;span class="s2">:&lt;/span>&lt;span class="si">{&lt;/span>&lt;span class="n">turn&lt;/span>&lt;span class="si">}&lt;/span>&lt;span class="s2">&amp;#34;&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">handler&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">CallbackHandler&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">trace_context&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="p">{&lt;/span>&lt;span class="s2">&amp;#34;trace_id&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="n">trace_id&lt;/span>&lt;span class="p">})&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">cfg&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="s2">&amp;#34;callbacks&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="p">[&lt;/span>&lt;span class="n">handler&lt;/span>&lt;span class="p">],&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="s2">&amp;#34;configurable&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="p">{&lt;/span>&lt;span class="s2">&amp;#34;thread_id&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="n">thread_id&lt;/span>&lt;span class="p">},&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">with&lt;/span> &lt;span class="n">propagate_attributes&lt;/span>&lt;span class="p">(&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">trace_name&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;agent-turn&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">user_id&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">user&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">session_id&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">thread_id&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">tags&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;agent&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s2">&amp;#34;support&amp;#34;&lt;/span>&lt;span class="p">],&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">):&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">out&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">app&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">invoke&lt;/span>&lt;span class="p">({&lt;/span>&lt;span class="s2">&amp;#34;messages&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="p">[&lt;/span>&lt;span class="n">HumanMessage&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">text&lt;/span>&lt;span class="p">)]},&lt;/span> &lt;span class="n">config&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">cfg&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="n">trace_id&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">out&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># 4. Resume after human approval: same seed, same trace.&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="k">def&lt;/span> &lt;span class="nf">resume_turn&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">thread_id&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="nb">str&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">turn&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="nb">int&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">decision&lt;/span>&lt;span class="p">):&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="kn">from&lt;/span> &lt;span class="nn">langgraph.types&lt;/span> &lt;span class="kn">import&lt;/span> &lt;span class="n">Command&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">trace_id&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">Langfuse&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">create_trace_id&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">seed&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="sa">f&lt;/span>&lt;span class="s2">&amp;#34;&lt;/span>&lt;span class="si">{&lt;/span>&lt;span class="n">thread_id&lt;/span>&lt;span class="si">}&lt;/span>&lt;span class="s2">:&lt;/span>&lt;span class="si">{&lt;/span>&lt;span class="n">turn&lt;/span>&lt;span class="si">}&lt;/span>&lt;span class="s2">&amp;#34;&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">handler&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">CallbackHandler&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">trace_context&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="p">{&lt;/span>&lt;span class="s2">&amp;#34;trace_id&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="n">trace_id&lt;/span>&lt;span class="p">})&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">cfg&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="p">{&lt;/span>&lt;span class="s2">&amp;#34;callbacks&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="p">[&lt;/span>&lt;span class="n">handler&lt;/span>&lt;span class="p">],&lt;/span> &lt;span class="s2">&amp;#34;configurable&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="p">{&lt;/span>&lt;span class="s2">&amp;#34;thread_id&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="n">thread_id&lt;/span>&lt;span class="p">}}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="n">app&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">invoke&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">Command&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">resume&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">decision&lt;/span>&lt;span class="p">),&lt;/span> &lt;span class="n">config&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">cfg&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># 5. Score the turn. Scores do not travel over OTLP: they have their own route.&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">get_client&lt;/span>&lt;span class="p">()&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">create_score&lt;/span>&lt;span class="p">(&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">trace_id&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">trace_id&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">name&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;resolved&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">value&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="mi">1&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">data_type&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;BOOLEAN&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Two notes on what is not in the example. &lt;code>sample_rate&lt;/code> is head sampling and per trace, so it samples whole turns, which is what you want: half an agent trace is no use to anybody. And if the process is a web server, the client is built once at startup, not per request, whereas the handler can be per turn with no problem, because with the seeded identifier it no longer holds state worth preserving.&lt;/p>
&lt;h2 id="agent-instrumentation-checklist">Agent instrumentation checklist&lt;/h2>
&lt;ul>
&lt;li>Nodes representing a model decision carry &lt;code>agent&lt;/code> in the name. The rest do not.&lt;/li>
&lt;li>Every turn has a deterministic trace identifier seeded with thread and turn number.&lt;/li>
&lt;li>LangGraph&amp;rsquo;s &lt;code>thread_id&lt;/code> goes as Langfuse&amp;rsquo;s &lt;code>session_id&lt;/code>, always.&lt;/li>
&lt;li>Trace attributes are set with &lt;code>propagate_attributes&lt;/code>, not as metadata on a subgraph.&lt;/li>
&lt;li>The gateway&amp;rsquo;s model alias is registered in the Langfuse model table, with a price.&lt;/li>
&lt;li>Tools doing work outside LangChain are instrumented with the Langfuse SDK, not with bare OpenTelemetry.&lt;/li>
&lt;li>There is an alert on ingestion queue depth, and the average number of steps per turn is known.&lt;/li>
&lt;li>Someone has measured, on the actual installation, how many observations and how many bytes a real turn produces. The figures in this article come from a synthetic bench.&lt;/li>
&lt;li>Sampling is set through an environment variable, so it can be lowered without deploying code.&lt;/li>
&lt;/ul>
&lt;h2 id="traps">Traps&lt;/h2>
&lt;p>&lt;strong>Type AGENT does not mean there is an agent.&lt;/strong> It means somebody wrote &lt;code>agent&lt;/code> in the name. And the other way around: a whole graph without that string produces not one agent-type observation.&lt;/p>
&lt;p>&lt;strong>Putting &lt;code>langfuse_session_id&lt;/code> in a subgraph&amp;rsquo;s metadata does nothing.&lt;/strong> It is only read on the root run.&lt;/p>
&lt;p>&lt;strong>An interrupt splits the trace in two.&lt;/strong> Unless you seed the identifier. And with several replicas, the handler&amp;rsquo;s in-memory state is never going to save you, because the resume can land on another pod.&lt;/p>
&lt;p>&lt;strong>Your OpenTelemetry spans do not arrive.&lt;/strong> The default filter drops them without warning if the scope is not in the list and there are no &lt;code>gen_ai.*&lt;/code> attributes.&lt;/p>
&lt;p>&lt;strong>The number to watch is not users, it is steps per turn.&lt;/strong> Volume grows with the square of the steps, and the steps are decided by the prompt, not by the infrastructure.&lt;/p>
&lt;p>&lt;strong>You store megabytes and the interface shows you a thousand characters.&lt;/strong> The materialised view&amp;rsquo;s truncation to two hundred characters and the thousand-character read ceiling are still there. You pay for the full ingestion and see a clipping, unless you open the specific observation.&lt;/p>
&lt;p>&lt;strong>Cost comes out at zero with the gateway in front.&lt;/strong> Model alias unknown to the price table.&lt;/p>
&lt;p>&lt;strong>&lt;code>flush_at&lt;/code> above the queue size blows up when constructing the client.&lt;/strong> With an OpenTelemetry &lt;code>ValueError&lt;/code> that does not mention Langfuse anywhere.&lt;/p>
&lt;h2 id="the-series-the-ten-articles">The series: the ten articles&lt;/h2>
&lt;ol>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/langfuse-v4-what-goes-into-a-trace/">What goes into a trace&lt;/a>: version 4&amp;rsquo;s data model, limits, precedences, scores, masking and indexes.&lt;/li>
&lt;li>Putting LangGraph in front (this article): instrumenting an agentic platform and the measured cost of one turn.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/langfuse-v4-real-agents-odr-gptr/">Two well-known agents instrumented&lt;/a>: Open Deep Research and GPT Researcher, with the measured figures of a real request.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/langfuse-v4-migrating-with-no-window/">Migrating from version 3 to 4 with no window&lt;/a>: the three write-mode steps, the resumable background migrations and where the rollback point of no return sits.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/langfuse-v4-worker-queues/">The worker queues&lt;/a>: the map of all thirty-nine, which pool to dedicate to each group, the per-queue switches, sharding and concurrency.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/clickhouse-capacity-langfuse-v4-bytes-per-observation/">Real ClickHouse capacity and cost&lt;/a>: how to measure bytes per observation with the system tables, the difference between the full table and the listings table, and the merge cost of full-text indexes.&lt;/li>
&lt;li>Retention, deletion and data protection: why a deletion does not free disk, the mask cleaner that ships disabled, the pending deletion queue and the S3 lifecycle that has to be implemented by hand.&lt;/li>
&lt;li>Backup and cross recovery: restore ordering across Postgres, ClickHouse and object storage, what each mismatch breaks, and how far event replay goes.&lt;/li>
&lt;li>Saturation runbook: what to alert on from the queue metrics, the stuck probes, draining through the readiness endpoint and the dead letter queue.&lt;/li>
&lt;li>Getting the data out: the blob storage integration to Parquet, batch exports and the metrics API, to build the data lake.&lt;/li>
&lt;/ol>
&lt;h2 id="see-also">See also&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/langfuse-v4-what-goes-into-a-trace/">Langfuse v4: what really goes into a trace&lt;/a>: the data model that explains why volume matters this much.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/litellm-langfuse-operational-pair/">LiteLLM and Langfuse: the operational pair&lt;/a>: the four queues where the trace gets lost between gateway and backend.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/gateway-jwt-audience-trace-identity-seams/">The gateway does not live alone&lt;/a>: why the trace&amp;rsquo;s user is not the Keycloak one.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/sizing-agents-decode-bottleneck/">Sizing the gateway for a fleet of agents&lt;/a>: the other side of the same volume problem.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/second-cost-vector-ai-agents-durable-execution-temporal/">Durable execution and the cost of agents&lt;/a>: what happens when the agent outlives the process that launched it.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/isolating-ai-agents-workstation-to-cluster/">Isolating AI agents per customer&lt;/a>: the isolation underneath all of this.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/evals-llm-layer-after-tracing/">Evals: the layer after tracing&lt;/a>: where an agent turn&amp;rsquo;s scores fit.&lt;/li>
&lt;/ul>
&lt;h2 id="sources">Sources&lt;/h2>
&lt;ul>
&lt;li>Own measurement of 13 September 2026: LangGraph 1.2.11, langchain-core 1.6.3 and Langfuse Python SDK 4.15.2, with OpenTelemetry&amp;rsquo;s &lt;code>InMemorySpanExporter&lt;/code> and &lt;code>GenericFakeChatModel&lt;/code>, no network.&lt;/li>
&lt;li>Langfuse SDK 4.15.2 source: &lt;code>langfuse/langchain/CallbackHandler.py&lt;/code>, &lt;code>langfuse/_client/span_filter.py&lt;/code>, &lt;code>langfuse/_client/span_processor.py&lt;/code>, &lt;code>langfuse/_client/span.py&lt;/code>.&lt;/li>
&lt;li>PyPI downloads over the last thirty days via &lt;a href="https://pypistats.org/">pypistats&lt;/a>, checked on 13 September 2026.&lt;/li>
&lt;li>GitHub stars for &lt;a href="https://github.com/langchain-ai/langgraph">LangGraph&lt;/a>, &lt;a href="https://github.com/crewAIInc/crewAI">CrewAI&lt;/a> and the &lt;a href="https://github.com/openai/openai-agents-python">OpenAI Agents SDK&lt;/a>, checked on 13 September 2026.&lt;/li>
&lt;/ul></description></item></channel></rss>