<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom"><channel><title>Self-Hosting on lo0 — Tech Blog</title><link>https://blog.lo0.es/en/tags/self-hosting/</link><description>Recent content in Self-Hosting on lo0 — Tech Blog</description><generator>Hugo -- gohugo.io</generator><language>en</language><lastBuildDate>Mon, 14 Sep 2026 08:30:00 +0200</lastBuildDate><atom:link href="https://blog.lo0.es/en/tags/self-hosting/index.xml" rel="self" type="application/rss+xml"/><item><title>Langfuse v4, day 2 (5 of 10): the worker's thirty-nine queues, and the three that decide whether you lose data</title><link>https://blog.lo0.es/en/posts/langfuse-v4-worker-queues/</link><pubDate>Mon, 14 Sep 2026 08:30:00 +0200</pubDate><guid>https://blog.lo0.es/en/posts/langfuse-v4-worker-queues/</guid><description>&lt;blockquote>
&lt;p>Fifth article in a series of ten about operating Langfuse v4 in production. The previous one covered &lt;a href="https://blog.lo0.es/en/posts/langfuse-v4-migrating-with-no-window/">migrating from version 3&lt;/a>. This one goes inside the worker that is already running. Verified against the Langfuse 4.35.0 source, commit &lt;code>2ee1908&lt;/code> of 14 September 2026.&lt;/p>
&lt;/blockquote>
&lt;h2 id="tldr">TL;DR&lt;/h2>
&lt;p>&lt;strong>Thirty-nine queues and thirty-two switches.&lt;/strong> The catalogue is in &lt;code>packages/shared/src/server/queues.ts:406&lt;/code>. The &lt;code>QUEUE_CONSUMER_*_IS_ENABLED&lt;/code> variables are read once at boot, so turning a queue off is a restart, not a hot change.&lt;/p>
&lt;p>&lt;strong>Sharding spreads nothing without Redis Cluster.&lt;/strong> The producer only applies the hash if &lt;code>REDIS_CLUSTER_ENABLED === &amp;quot;true&amp;quot;&lt;/code>, and that variable ships &lt;code>&amp;quot;false&amp;quot;&lt;/code>. Raising &lt;code>LANGFUSE_INGESTION_QUEUE_SHARD_COUNT&lt;/code> on an installation with single-node Redis starts N workers and leaves N−1 with no work.&lt;/p>
&lt;p>&lt;strong>The dead-letter retrier ships disabled.&lt;/strong> &lt;code>QUEUE_CONSUMER_DEAD_LETTER_RETRY_QUEUE_IS_ENABLED&lt;/code> is the only one of the thirty-two whose default is &lt;code>&amp;quot;false&amp;quot;&lt;/code>.&lt;/p>
&lt;p>&lt;strong>And even switched on, it does not cover ingestion.&lt;/strong> Its list holds five queues and none is &lt;code>ingestion-queue&lt;/code>. An event that exhausts its six attempts sits in the failed set.&lt;/p>
&lt;p>&lt;strong>The ClickHouse writer drops rows.&lt;/strong> Three attempts with a flat hundred-millisecond delay and then out. The code comment is explicit: &lt;code>// TODO - Add to a dead letter queue in Redis rather than dropping&lt;/code>. The metric to watch is called &lt;code>langfuse.queue.clickhouse_writer.rows_dropped&lt;/code>.&lt;/p>
&lt;p>&lt;strong>Turn a queue&amp;rsquo;s consumer off and you stop seeing its depth.&lt;/strong> The metrics emitter only polls queues with a worker registered in that container. The queue keeps growing and the dashboard goes flat.&lt;/p>
&lt;p>&lt;strong>Project deletion runs one job every ten minutes.&lt;/strong> By limiter, deliberately, because each one scours ClickHouse.&lt;/p>
&lt;p>&lt;strong>There are two optional health probes and one of them can cause the very restart loop it is meant to fix&lt;/strong> if configured with less than sixty seconds of initial delay.&lt;/p>
&lt;h2 id="you-are-here-observe-day-2">You are here: OBSERVE, day 2&lt;/h2>
&lt;p>The previous article left the installation migrated. This is the map of the process that does the work, and it comes before the capacity article because the queues are where saturation shows up before ClickHouse notices.&lt;/p>
&lt;h2 id="the-analogy-the-sorting-hub">The analogy: the sorting hub&lt;/h2>
&lt;p>A postal sorting hub has separate belts for parcels, registered mail, returns and internal pouches. Each belt has its speed and its staff, and some are throttled on purpose because the destination cannot take more.&lt;/p>
&lt;p>The job of whoever runs it is not to watch the fastest belt, it is to know which one jams first, which can stop for an afternoon without anyone noticing and which cannot stop for ten minutes. And above all, where the bin for items nobody could sort is, because every hub has one and in many of them nobody looks at it.&lt;/p>
&lt;p>The Langfuse worker has thirty-nine belts. This article is the plan, with the speeds and the location of the bin.&lt;/p>
&lt;h2 id="the-map-of-the-thirty-nine">The map of the thirty-nine&lt;/h2>
&lt;p>Grouped by what they do, with what stops happening if the group stops:&lt;/p>
&lt;p>&lt;strong>Ingestion (4).&lt;/strong> &lt;code>ingestion-queue&lt;/code>, &lt;code>secondary-ingestion-queue&lt;/code>, &lt;code>otel-ingestion-queue&lt;/code>, &lt;code>secondary-otel-ingestion-queue&lt;/code>. If they stop, the web keeps accepting events and uploading them to object storage, and keeps enqueueing. Nothing reaches ClickHouse. The SDK sees no error at all. This is the group that cannot stop.&lt;/p>
&lt;p>&lt;strong>Propagation (1).&lt;/strong> &lt;code>event-propagation-queue&lt;/code>, the dual-write one from the previous article, with a global concurrency of one across the cluster.&lt;/p>
&lt;p>&lt;strong>Evaluation (8).&lt;/strong> &lt;code>trace-upsert&lt;/code>, &lt;code>dataset-run-item-upsert-queue&lt;/code>, &lt;code>create-eval-queue&lt;/code>, &lt;code>evaluation-execution-queue&lt;/code> and its secondary, &lt;code>llm-as-a-judge-execution-queue&lt;/code>, &lt;code>code-eval-execution-queue&lt;/code>, &lt;code>experiment-create-queue&lt;/code>. If they stop, evaluations are neither created nor executed. Traces keep arriving. This is the group that can wait.&lt;/p>
&lt;p>&lt;strong>Deletion and retention (7).&lt;/strong> &lt;code>trace-delete&lt;/code>, &lt;code>score-delete&lt;/code>, &lt;code>dataset-delete-queue&lt;/code>, &lt;code>project-delete&lt;/code>, &lt;code>data-retention-queue&lt;/code> and its processing queue, &lt;code>batch-action-queue&lt;/code>. If they stop, neither UI-requested deletions nor per-project retention run. That is not an operational annoyance, it is a breach if you have a retention commitment in writing.&lt;/p>
&lt;p>&lt;strong>Exports (3).&lt;/strong> &lt;code>batch-export-queue&lt;/code>, &lt;code>core-data-s3-export-queue&lt;/code>, &lt;code>metering-data-postgres-export-queue&lt;/code>. Downloads a user asked for stay pending.&lt;/p>
&lt;p>&lt;strong>Outbound integrations (8).&lt;/strong> The PostHog, Mixpanel and blob storage pairs, plus &lt;code>webhook-queue&lt;/code>, &lt;code>entity-change-queue&lt;/code> and &lt;code>notification-queue&lt;/code>. In each pair, the bare queue is the scheduler that fans out per project and the processing one does the work.&lt;/p>
&lt;p>&lt;strong>Cloud billing (4).&lt;/strong> They only register with a Stripe key. On a self-hosted installation they never start.&lt;/p>
&lt;p>&lt;strong>Maintenance (4).&lt;/strong> &lt;code>dead-letter-retry-queue&lt;/code>, &lt;code>monitor-queue&lt;/code>, &lt;code>in-app-agent-run-queue&lt;/code> and &lt;code>v4-legacy-api-usage-queue&lt;/code>, which ingests nothing: it is a cron that scans ClickHouse&amp;rsquo;s &lt;code>system.query_log&lt;/code> to find which projects still use the old API.&lt;/p>
&lt;p>The switches number thirty-two, all &lt;code>QUEUE_CONSUMER_*_IS_ENABLED&lt;/code> and all &lt;code>&amp;quot;true&amp;quot;&lt;/code> except the dead-letter retrier. Four queues have no switch of their own: the model-as-judge one rides inside the evaluation switch, the two Cloud data export queues have their own variable, and the processing queues share a switch with their scheduler.&lt;/p>
&lt;p>The mechanism is a top-level &lt;code>if&lt;/code> around each worker registration. A false switch means the worker is never instantiated, not that the queue stops receiving work: &lt;strong>producers keep enqueueing regardless&lt;/strong>. Turning a queue off is not consuming it, not not filling it.&lt;/p>
&lt;h2 id="the-sharding-that-does-not-shard">The sharding that does not shard&lt;/h2>
&lt;p>Nine queues support sharding: the four ingestion ones, three evaluation ones, model-as-judge and &lt;code>trace-upsert&lt;/code>. Each has its own shard-count variable, and &lt;strong>they all default to 1&lt;/strong>.&lt;/p>
&lt;p>Raising them looks like the obvious first move to scale ingestion. It is not, because of this (&lt;code>packages/shared/src/server/redis/ingestionQueue.ts:48&lt;/code>):&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-ts" data-lang="ts">&lt;span class="line">&lt;span class="cl"> &lt;span class="kr">const&lt;/span> &lt;span class="nx">shardIndex&lt;/span> &lt;span class="o">=&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nx">IngestionQueue&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">getShardIndexFromShardName&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">shardName&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="o">??&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">(&lt;/span>&lt;span class="nx">env&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">REDIS_CLUSTER_ENABLED&lt;/span> &lt;span class="o">===&lt;/span> &lt;span class="s2">&amp;#34;true&amp;#34;&lt;/span> &lt;span class="o">&amp;amp;&amp;amp;&lt;/span> &lt;span class="nx">shardingKey&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="o">?&lt;/span> &lt;span class="nx">getShardIndex&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">shardingKey&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">env&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">LANGFUSE_INGESTION_QUEUE_SHARD_COUNT&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="o">:&lt;/span> &lt;span class="mi">0&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>The &lt;code>: 0&lt;/code> at the end is the whole story. The producer only computes a shard if Redis is in cluster mode, and &lt;code>REDIS_CLUSTER_ENABLED&lt;/code> ships &lt;code>&amp;quot;false&amp;quot;&lt;/code>. On an installation with single-node Redis, which is most self-hosted installations, raising the shard count to four starts four workers, three of which stare at empty queues, while all traffic keeps coming through &lt;code>ingestion-queue&lt;/code>.&lt;/p>
&lt;p>The sharding key, when it does apply, is &lt;code>projectId-eventBodyId&lt;/code> run through SHA-256, taking the first eight hex characters and a modulo. Shard names are &lt;code>ingestion-queue&lt;/code> for zero and &lt;code>ingestion-queue-N&lt;/code> for the rest.&lt;/p>
&lt;p>Without Redis Cluster, the lever that does work is concurrency, which is a different thing.&lt;/p>
&lt;h2 id="concurrencies-and-the-ones-set-to-one-on-purpose">Concurrencies, and the ones set to one on purpose&lt;/h2>
&lt;p>The defaults, worth knowing because several sit at one for good reasons:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Queue&lt;/th>
&lt;th>Variable&lt;/th>
&lt;th>Default&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>&lt;code>ingestion-queue&lt;/code> (per shard)&lt;/td>
&lt;td>&lt;code>LANGFUSE_INGESTION_QUEUE_PROCESSING_CONCURRENCY&lt;/code>&lt;/td>
&lt;td>&lt;strong>20&lt;/strong>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>secondary-ingestion-queue&lt;/code>&lt;/td>
&lt;td>&lt;code>LANGFUSE_INGESTION_SECONDARY_QUEUE_PROCESSING_CONCURRENCY&lt;/code>&lt;/td>
&lt;td>5&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>otel-ingestion-queue&lt;/code>&lt;/td>
&lt;td>&lt;code>LANGFUSE_OTEL_INGESTION_QUEUE_PROCESSING_CONCURRENCY&lt;/code>&lt;/td>
&lt;td>5&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>trace-upsert&lt;/code>&lt;/td>
&lt;td>&lt;code>LANGFUSE_TRACE_UPSERT_WORKER_CONCURRENCY&lt;/code>&lt;/td>
&lt;td>&lt;strong>25&lt;/strong>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>monitor-queue&lt;/code>&lt;/td>
&lt;td>&lt;code>LANGFUSE_MONITOR_QUEUE_PROCESSING_CONCURRENCY&lt;/code>&lt;/td>
&lt;td>10&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>evaluation and model-as-judge&lt;/td>
&lt;td>several&lt;/td>
&lt;td>5&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>create-eval-queue&lt;/code>&lt;/td>
&lt;td>&lt;code>LANGFUSE_EVAL_CREATOR_WORKER_CONCURRENCY&lt;/code>&lt;/td>
&lt;td>2&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>trace-delete&lt;/code>, &lt;code>score-delete&lt;/code>, &lt;code>dataset-delete-queue&lt;/code>, &lt;code>project-delete&lt;/code>&lt;/td>
&lt;td>several&lt;/td>
&lt;td>&lt;strong>1&lt;/strong>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>event-propagation-queue&lt;/code>&lt;/td>
&lt;td>fixed in code&lt;/td>
&lt;td>&lt;strong>1 cluster-wide&lt;/strong>&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>Deletions sit at one and carry a rate limiter on top. The project one is the most aggressive (&lt;code>worker/src/app.ts:234&lt;/code>):&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-ts" data-lang="ts">&lt;span class="line">&lt;span class="cl"> &lt;span class="nx">limiter&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="c1">// Process at most `max` delete jobs per LANGFUSE_CLICKHOUSE_PROJECT_DELETION_CONCURRENCY_DURATION_MS (default 10 min)
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1">&lt;/span> &lt;span class="nx">max&lt;/span>: &lt;span class="kt">env.LANGFUSE_PROJECT_DELETE_CONCURRENCY&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>One project deletion every ten minutes. If a customer asks you to delete forty projects, that is six hours and forty minutes on the clock, and rightly so, because each of those deletions is a broom sweeping through ClickHouse.&lt;/p>
&lt;p>The ingestion queues, by contrast, have &lt;strong>no limiter at all&lt;/strong>. There the brake is concurrency and the ClickHouse writer.&lt;/p>
&lt;p>One BullMQ detail that recurs across queues and explains a good number of other people&amp;rsquo;s stalls (&lt;code>worker/src/app.ts:261&lt;/code>):&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-ts" data-lang="ts">&lt;span class="line">&lt;span class="cl"> &lt;span class="c1">// The default lockDuration is 30s and the lockRenewTime 1/2 of that.
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1">&lt;/span> &lt;span class="c1">// We set it to 60s to reduce the number of lock renewals and also be less sensitive to high CPU wait times.
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1">&lt;/span> &lt;span class="nx">lockDuration&lt;/span>: &lt;span class="kt">60000&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="c1">// 60 seconds
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1">&lt;/span> &lt;span class="nx">stalledInterval&lt;/span>: &lt;span class="kt">120000&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="c1">// 120 seconds
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1">&lt;/span> &lt;span class="nx">maxStalledCount&lt;/span>: &lt;span class="kt">3&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h2 id="an-events-path-from-api-to-clickhouse">An event&amp;rsquo;s path, from API to ClickHouse&lt;/h2>
&lt;p>It is worth following it end to end once, because there are three places it can be lost.&lt;/p>
&lt;p>The web groups the batch&amp;rsquo;s events by body id, uploads one JSON file per group to object storage and &lt;strong>aborts if that upload fails&lt;/strong>: no blob, no enqueue. Then it enqueues one job per body id on &lt;code>ingestion-queue&lt;/code>.&lt;/p>
&lt;p>The worker picks up the job, optionally checks a recently-processed cache in Redis (off by default, five-minute lifetime), decides whether to redirect the project to the secondary queue, and downloads the file from storage. There is a comment here worth a capacity chart on its own:&lt;/p>
&lt;blockquote>
&lt;p>Process files in batches. If a user has 5k events, this will likely take 100 seconds.&lt;/p>
&lt;/blockquote>
&lt;p>Then it merges and writes. And writing is not writing: the service does not talk to ClickHouse, it puts the row in a buffer whose defaults are &lt;strong>1,000 rows, 1,000 milliseconds and 3 attempts&lt;/strong>.&lt;/p>
&lt;p>That buffer is the third place things get lost, and the most serious one in the system (&lt;code>worker/src/services/ClickhouseWriter/index.ts:535&lt;/code>):&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-ts" data-lang="ts">&lt;span class="line">&lt;span class="cl"> &lt;span class="c1">// Re-add the records to the queue with incremented attempts
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1">&lt;/span> &lt;span class="kd">let&lt;/span> &lt;span class="nx">droppedCount&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="mi">0&lt;/span>&lt;span class="p">;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nx">queueItems&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">forEach&lt;/span>&lt;span class="p">((&lt;/span>&lt;span class="nx">item&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="o">=&amp;gt;&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="p">(&lt;/span>&lt;span class="nx">item&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">attempts&lt;/span> &lt;span class="o">&amp;lt;&lt;/span> &lt;span class="k">this&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">maxAttempts&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="nx">entityQueue&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">push&lt;/span>&lt;span class="p">({&lt;/span> &lt;span class="p">...&lt;/span>&lt;span class="nx">item&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">attempts&lt;/span>: &lt;span class="kt">item.attempts&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="p">}&lt;/span> &lt;span class="k">else&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="c1">// TODO - Add to a dead letter queue in Redis rather than dropping
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1">&lt;/span> &lt;span class="nx">recordIncrement&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;langfuse.queue.clickhouse_writer.error&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="nx">droppedCount&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="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>Once the three attempts are spent, the row is dropped. And no queue retry saves it, because the BullMQ job already completed successfully: as far as the queue is concerned, that event was processed. The internal retries are not properly exponential either: the delay is flat at a hundred milliseconds, so all three attempts happen within three hundred milliseconds and a ClickHouse that is unwell for half a second takes the batch with it.&lt;/p>
&lt;p>The runbook consequence is direct. &lt;strong>&lt;code>langfuse.queue.clickhouse_writer.rows_dropped&lt;/code> has to be on a dashboard and it has to have an alert at zero.&lt;/strong> Any non-zero value is silent data loss.&lt;/p>
&lt;h2 id="retries-and-the-bin-nobody-looks-at">Retries, and the bin nobody looks at&lt;/h2>
&lt;p>Each queue defines its own policy. The ones that matter:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Queue&lt;/th>
&lt;th>Attempts&lt;/th>
&lt;th>Backoff&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>&lt;code>ingestion-queue&lt;/code>&lt;/td>
&lt;td>6&lt;/td>
&lt;td>exponential, 5 s&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>secondary-ingestion-queue&lt;/code>&lt;/td>
&lt;td>5&lt;/td>
&lt;td>exponential, 5 s&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>evaluation-execution-queue&lt;/code>&lt;/td>
&lt;td>10&lt;/td>
&lt;td>exponential, 1 s&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>code-eval-execution-queue&lt;/code>&lt;/td>
&lt;td>3&lt;/td>
&lt;td>exponential, 30 s&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>trace-upsert&lt;/code>&lt;/td>
&lt;td>2&lt;/td>
&lt;td>exponential, 5 s, with a 30 s initial delay&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>trace-delete&lt;/code>, &lt;code>score-delete&lt;/code>&lt;/td>
&lt;td>2&lt;/td>
&lt;td>exponential, 30 s&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>monitor-queue&lt;/code>, &lt;code>in-app-agent-run-queue&lt;/code>&lt;/td>
&lt;td>&lt;strong>1&lt;/strong>&lt;/td>
&lt;td>no backoff, deliberately&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>The two single-attempt ones are a design decision, and the comment explains it well: &lt;em>&amp;ldquo;Postgres owns correctness (claim CAS + reconcile-on-read); BullMQ is delivery-only, so never redeliver on its own.&amp;rdquo;&lt;/em>&lt;/p>
&lt;p>And now the bin. &lt;strong>There is no dedicated dead letter queue.&lt;/strong> What exists is BullMQ&amp;rsquo;s native &lt;code>failed&lt;/code> set per queue, with its own retention, plus a queue that retries from there. That queue has two problems for whoever operates it:&lt;/p>
&lt;p>First, &lt;strong>it ships disabled&lt;/strong>. It is the only one of the thirty-two defaulting to &lt;code>&amp;quot;false&amp;quot;&lt;/code>.&lt;/p>
&lt;p>Second, even switched on, its scope is hand-written and has five entries (&lt;code>worker/src/services/dlq/dlqRetryService.ts:9&lt;/code>):&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-ts" data-lang="ts">&lt;span class="line">&lt;span class="cl"> &lt;span class="kr">private&lt;/span> &lt;span class="kr">static&lt;/span> &lt;span class="nx">retryQueues&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="nx">QueueName&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">ProjectDelete&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nx">QueueName&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">TraceDelete&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nx">QueueName&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">ScoreDelete&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nx">QueueName&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">BatchActionQueue&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nx">QueueName&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">DataRetentionProcessingQueue&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="kr">as&lt;/span> &lt;span class="kr">const&lt;/span>&lt;span class="p">;&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;code>ingestion-queue&lt;/code> is not there. Ingestion events that exhaust their six attempts sit in the failed set, up to a hundred thousand of them, and nobody ever retries them. To get them out there is an admin route, &lt;code>POST /api/admin/bullmq&lt;/code> with action &lt;code>retry&lt;/code>, which the file itself describes as the one the managed service uses for exactly this.&lt;/p>
&lt;h2 id="the-metrics-with-their-exact-names">The metrics, with their exact names&lt;/h2>
&lt;p>Each queue&amp;rsquo;s metric name is built at runtime from the queue name, turning dashes into underscores and stripping the &lt;code>_queue&lt;/code> suffix. So &lt;code>ingestion-queue&lt;/code> produces &lt;code>langfuse.queue.ingestion&lt;/code>, and &lt;code>secondary-ingestion-queue&lt;/code> produces &lt;code>langfuse.queue.secondary_ingestion&lt;/code>. Sharded queues always add a &lt;code>shard&lt;/code> tag.&lt;/p>
&lt;p>What comes out of that:&lt;/p>
&lt;ul>
&lt;li>&lt;code>langfuse.queue.&amp;lt;queue&amp;gt;.rate&lt;/code> with &lt;code>type&lt;/code> in &lt;code>request&lt;/code>, &lt;code>completed&lt;/code>, &lt;code>failed&lt;/code>, &lt;code>error&lt;/code> and &lt;code>stalled&lt;/code>.&lt;/li>
&lt;li>&lt;code>langfuse.queue.&amp;lt;queue&amp;gt;.time_distribution&lt;/code> with &lt;code>type&lt;/code> in &lt;code>wait&lt;/code> and &lt;code>processing&lt;/code>.&lt;/li>
&lt;li>&lt;code>langfuse.queue.&amp;lt;queue&amp;gt;.depth&lt;/code> with &lt;code>type&lt;/code> in &lt;code>waiting&lt;/code>, &lt;code>failed&lt;/code> and &lt;code>active&lt;/code>, plus an aggregate series with &lt;code>shard: &amp;quot;all&amp;quot;&lt;/code>.&lt;/li>
&lt;li>&lt;code>langfuse.queue.&amp;lt;queue&amp;gt;.dlq_oldest_age&lt;/code>, the age of the oldest failed job, which emits zero when the set empties so the dashboard resets.&lt;/li>
&lt;li>From the writer: &lt;code>langfuse.queue.clickhouse_writer.rows_dropped&lt;/code>, &lt;code>.error&lt;/code>, &lt;code>.processing_time&lt;/code> and &lt;code>ingestion_clickhouse_insert_queue_length&lt;/code>.&lt;/li>
&lt;/ul>
&lt;p>Two things to know about those metrics. The &lt;code>waiting&lt;/code> depth &lt;strong>includes paused jobs&lt;/strong>, to match BullMQ&amp;rsquo;s counter. And the big trap (&lt;code>worker/src/features/queue-metrics-runner/index.ts:70&lt;/code>):&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-ts" data-lang="ts">&lt;span class="line">&lt;span class="cl"> &lt;span class="c1">// Only poll queues that have registered workers. This avoids calling
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1">&lt;/span> &lt;span class="c1">// getInstance() on queues this worker doesn&amp;#39;t consume, which would
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1">&lt;/span> &lt;span class="c1">// create unnecessary Redis connections and can trigger side effects
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Only queues that container consumes emit metrics. If you split roles and turn a queue&amp;rsquo;s consumer off across every container, that queue &lt;strong>stops appearing on dashboards&lt;/strong> while it keeps growing in Redis. It is the perfect way not to find out.&lt;/p>
&lt;p>The metric that saturates first is still the same one: &lt;code>langfuse.queue.ingestion.depth&lt;/code> with &lt;code>type: &amp;quot;waiting&amp;quot;&lt;/code>. The emission interval defaults to one second.&lt;/p>
&lt;h2 id="the-two-probes-and-the-one-that-can-restart-you-in-a-loop">The two probes, and the one that can restart you in a loop&lt;/h2>
&lt;p>The worker exposes &lt;code>/api/health&lt;/code> and &lt;code>/api/ready&lt;/code>, identical but for one thing: the second returns 500 after SIGTERM, which is how draining works. Both always check Postgres with a &lt;code>SELECT 1&lt;/code> and Redis with a ping capped at &lt;strong>two seconds, hardcoded&lt;/strong>.&lt;/p>
&lt;p>Then there are two optional checks enabled by query parameter:&lt;/p>
&lt;p>&lt;strong>&lt;code>?failIfEventPropagationStuck=true&lt;/code>&lt;/strong> looks at the propagator&amp;rsquo;s heartbeat and returns 503 if it has not been refreshed for more than 35 minutes. A missing heartbeat does not count as stuck, deliberately, so a freshly booted container is not restarted in a loop. And the condition that must be respected:&lt;/p>
&lt;blockquote>
&lt;p>Probes using this flag MUST set &lt;code>initialDelaySeconds &amp;gt;= 60s&lt;/code> (one cron cycle) [&amp;hellip;] A shorter delay can crash-loop the very restart this check triggers.&lt;/p>
&lt;/blockquote>
&lt;p>&lt;strong>&lt;code>?failIfQueueConsumptionStuck=true&lt;/code>&lt;/strong> is an in-memory signal of that container, with no Redis involved: it is marked on the &lt;code>active&lt;/code> and &lt;code>completed&lt;/code> events of any worker. It returns 503 if that container has gone 60 minutes without picking up or finishing a single job. It exists for a very specific failure mode:&lt;/p>
&lt;blockquote>
&lt;p>After Redis lock loss BullMQ workers can wedge permanently: the process stays alive and connectivity checks pass, but no queue picks up jobs ever again.&lt;/p>
&lt;/blockquote>
&lt;p>The one-hour threshold leans on the default crons keeping a healthy worker busy at least once an hour. In multi-replica deployments each scheduler tick lands on a single replica, so the comment itself warns about raising the threshold if a replica can legitimately sit idle.&lt;/p>
&lt;p>Draining, by the way, has no cap. The order is close the HTTP server, stop the periodic runners, close the BullMQ workers and &lt;strong>then&lt;/strong> flush the ClickHouse writer, which on shutdown empties the whole queue rather than one batch. The only thing bounding that drain is the Kubernetes grace period, so it had better be generous.&lt;/p>
&lt;h2 id="the-crons">The crons&lt;/h2>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Queue&lt;/th>
&lt;th>Pattern&lt;/th>
&lt;th>What it does&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>&lt;code>event-propagation-queue&lt;/code>&lt;/td>
&lt;td>&lt;code>* * * * *&lt;/code>&lt;/td>
&lt;td>propagates one staging partition&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>dead-letter-retry-queue&lt;/code>&lt;/td>
&lt;td>&lt;code>0 */10 * * * *&lt;/code>&lt;/td>
&lt;td>retries failed jobs from five queues&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>v4-legacy-api-usage-queue&lt;/code>&lt;/td>
&lt;td>&lt;code>*/15 * * * *&lt;/code>&lt;/td>
&lt;td>scans &lt;code>system.query_log&lt;/code> for old-API usage&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>blobstorage-integration-queue&lt;/code>&lt;/td>
&lt;td>&lt;code>*/20 * * * *&lt;/code>&lt;/td>
&lt;td>fans exports out per project&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>cloud-usage-metering-queue&lt;/code>&lt;/td>
&lt;td>&lt;code>5 * * * *&lt;/code>&lt;/td>
&lt;td>Cloud billing&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>posthog-integration-queue&lt;/code>, &lt;code>mixpanel-integration-queue&lt;/code>&lt;/td>
&lt;td>&lt;code>30 * * * *&lt;/code>&lt;/td>
&lt;td>hourly fan-out&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>metering-data-postgres-export-queue&lt;/code>&lt;/td>
&lt;td>&lt;code>30 2 * * *&lt;/code>&lt;/td>
&lt;td>daily export&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>core-data-s3-export-queue&lt;/code>, &lt;code>data-retention-queue&lt;/code>&lt;/td>
&lt;td>&lt;code>15 3 * * *&lt;/code>&lt;/td>
&lt;td>daily export and retention&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>And a warning the scheduler file itself writes in capitals: cron registration is fire-and-forget from the queue constructors, and a failure is only logged. A transient Redis failure at boot can leave a cron unscheduled until the next restart, &lt;strong>without the container ever being marked unhealthy&lt;/strong>.&lt;/p>
&lt;h2 id="splitting-roles-which-is-what-all-this-is-for">Splitting roles, which is what all this is for&lt;/h2>
&lt;p>With thirty-two switches, what you can build is one worker per family. The split that makes sense on an installation with agentic load:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># Pool 1: ingestion. This is the one that scales with traffic.&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="nv">QUEUE_CONSUMER_INGESTION_QUEUE_IS_ENABLED&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="nb">true&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="nv">QUEUE_CONSUMER_OTEL_INGESTION_QUEUE_IS_ENABLED&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="nb">true&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="nv">QUEUE_CONSUMER_EVENT_PROPAGATION_QUEUE_IS_ENABLED&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="nb">true&lt;/span> &lt;span class="c1"># note: global concurrency 1&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="nv">LANGFUSE_INGESTION_QUEUE_PROCESSING_CONCURRENCY&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="m">20&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># everything else false&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"># Pool 2: evaluations. Scales with the number of evaluators, not with traffic.&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="nv">QUEUE_CONSUMER_EVAL_EXECUTION_QUEUE_IS_ENABLED&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="nb">true&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="nv">QUEUE_CONSUMER_CREATE_EVAL_QUEUE_IS_ENABLED&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="nb">true&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="nv">QUEUE_CONSUMER_TRACE_UPSERT_QUEUE_IS_ENABLED&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="nb">true&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="nv">QUEUE_CONSUMER_EXPERIMENT_CREATE_QUEUE_IS_ENABLED&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="nb">true&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"># Pool 3: maintenance. One replica is enough.&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="nv">QUEUE_CONSUMER_TRACE_DELETE_QUEUE_IS_ENABLED&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="nb">true&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="nv">QUEUE_CONSUMER_PROJECT_DELETE_QUEUE_IS_ENABLED&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="nb">true&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="nv">QUEUE_CONSUMER_DATA_RETENTION_QUEUE_IS_ENABLED&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="nb">true&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="nv">QUEUE_CONSUMER_BATCH_EXPORT_QUEUE_IS_ENABLED&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="nb">true&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="nv">QUEUE_CONSUMER_DEAD_LETTER_RETRY_QUEUE_IS_ENABLED&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="nb">true&lt;/span> &lt;span class="c1"># switch it on&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Two warnings about this split. Each worker registration opens &lt;strong>its own Redis connection&lt;/strong>, so the connection count grows with queues times shards times replicas, and that is checked against Redis &lt;code>maxclients&lt;/code> before multiplying replicas. And the moment you split roles you lose the metrics of the queues that container does not consume, so &lt;strong>some&lt;/strong> replica needs to consume every queue or you go blind on that one.&lt;/p>
&lt;h2 id="checklist">Checklist&lt;/h2>
&lt;ul>
&lt;li>There is an alert on &lt;code>langfuse.queue.clickhouse_writer.rows_dropped&lt;/code>, threshold zero.&lt;/li>
&lt;li>There is an alert on the depth of &lt;code>langfuse.queue.ingestion&lt;/code> with &lt;code>type: &amp;quot;waiting&amp;quot;&lt;/code>.&lt;/li>
&lt;li>There is an alert on &lt;code>dlq_oldest_age&lt;/code> for the queues that matter, and somebody knows how to use &lt;code>POST /api/admin/bullmq&lt;/code>.&lt;/li>
&lt;li>The dead-letter retrier is on in some replica, knowing it does not cover ingestion.&lt;/li>
&lt;li>Nobody raised the shard count without Redis Cluster.&lt;/li>
&lt;li>The optional health probes are configured with &lt;code>initialDelaySeconds&lt;/code> of at least 60 seconds.&lt;/li>
&lt;li>The Kubernetes termination grace period allows the ClickHouse writer to flush.&lt;/li>
&lt;li>Every queue is consumed by at least one replica, so its metrics are not lost.&lt;/li>
&lt;li>Redis &lt;code>maxclients&lt;/code> copes with queues times shards times replicas.&lt;/li>
&lt;/ul>
&lt;h2 id="traps">Traps&lt;/h2>
&lt;p>&lt;strong>Sharding without Redis Cluster does not spread load.&lt;/strong> It starts the workers and leaves them idle.&lt;/p>
&lt;p>&lt;strong>The dead-letter retrier ships disabled and does not cover ingestion.&lt;/strong> Two independent facts, both uncomfortable.&lt;/p>
&lt;p>&lt;strong>The ClickHouse writer drops rows after three attempts.&lt;/strong> With a TODO in the code admitting it and three hundred milliseconds of total headroom.&lt;/p>
&lt;p>&lt;strong>Turning a consumer off also turns its metrics off.&lt;/strong> The queue keeps growing, the dashboard stays flat.&lt;/p>
&lt;p>&lt;strong>A cron can end up unscheduled while the container stays healthy.&lt;/strong> A transient Redis failure at boot, and it is gone until the next restart.&lt;/p>
&lt;p>&lt;strong>The PostHog and blob storage integrations disable themselves&lt;/strong> when an error is classified as a customer-config fault, and if the notification also fails they stay off and silent.&lt;/p>
&lt;p>&lt;strong>A misconfigured propagation probe restarts in a loop.&lt;/strong> Less than sixty seconds of initial delay and you get the cycle.&lt;/p>
&lt;p>&lt;strong>Draining has no cap of its own.&lt;/strong> The Kubernetes grace period bounds it and nothing else.&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>&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>The worker queues (this article): 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-migrating-with-no-window/">Migrating from version 3 to 4 with no window&lt;/a>: this article&amp;rsquo;s propagation queue is that one&amp;rsquo;s dual-write.&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>: what the ClickHouse writer ends up writing.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/langfuse-v4-instrumenting-langgraph/">Putting LangGraph in front&lt;/a>: where the volume that fills these queues comes from.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/langfuse-inside-sorting-centre-bottleneck/">Langfuse from the inside&lt;/a>: the six-service architecture and the performance levers.&lt;/li>
&lt;/ul>
&lt;h2 id="sources">Sources&lt;/h2>
&lt;ul>
&lt;li>Langfuse 4.35.0 source, commit &lt;code>2ee1908&lt;/code> of 14 September 2026: &lt;code>packages/shared/src/server/queues.ts&lt;/code>, &lt;code>packages/shared/src/server/redis/&lt;/code>, &lt;code>worker/src/env.ts&lt;/code>, &lt;code>worker/src/app.ts&lt;/code>, &lt;code>worker/src/queues/&lt;/code>, &lt;code>worker/src/services/ClickhouseWriter/&lt;/code>, &lt;code>worker/src/services/dlq/&lt;/code>, &lt;code>worker/src/features/health/&lt;/code>, &lt;code>worker/src/features/queue-metrics-runner/&lt;/code>, &lt;code>worker/src/utils/shutdown.ts&lt;/code>.&lt;/li>
&lt;li>&lt;a href="https://langfuse.com/self-hosting">Langfuse self-hosting documentation&lt;/a>, checked on 14 September 2026.&lt;/li>
&lt;li>&lt;a href="https://docs.bullmq.io/guide/going-to-production">BullMQ: going to production&lt;/a>, checked on 14 September 2026.&lt;/li>
&lt;/ul></description></item><item><title>Langfuse v4, day 2 (4 of 10): migrating from 3 to 4 with no window, and where the point of no return sits</title><link>https://blog.lo0.es/en/posts/langfuse-v4-migrating-with-no-window/</link><pubDate>Mon, 14 Sep 2026 08:00:00 +0200</pubDate><guid>https://blog.lo0.es/en/posts/langfuse-v4-migrating-with-no-window/</guid><description>&lt;blockquote>
&lt;p>Fourth article in a series of ten about operating Langfuse v4 in production. The previous three looked at the client side: &lt;a href="https://blog.lo0.es/en/posts/langfuse-v4-what-goes-into-a-trace/">what goes into a trace&lt;/a>, &lt;a href="https://blog.lo0.es/en/posts/langfuse-v4-instrumenting-langgraph/">what an agent turn costs&lt;/a> and &lt;a href="https://blog.lo0.es/en/posts/langfuse-v4-real-agents-odr-gptr/">two well-known agents instrumented&lt;/a>. This is the first one on the server side. Verified against the Langfuse 4.35.0 source, commit &lt;code>2ee1908&lt;/code> of 14 September 2026.&lt;/p>
&lt;/blockquote>
&lt;h2 id="tldr">TL;DR&lt;/h2>
&lt;p>&lt;strong>The default write mode is the destination, not the origin.&lt;/strong> &lt;code>LANGFUSE_MIGRATION_V4_WRITE_MODE&lt;/code> ships as &lt;code>events_only&lt;/code> (&lt;code>packages/shared/src/env.ts:337&lt;/code>). That is right for a new installation and wrong for a migration: in that mode the &lt;code>traces&lt;/code> and &lt;code>observations&lt;/code> tables stop being written.&lt;/p>
&lt;p>&lt;strong>The worker refuses to start on three combinations.&lt;/strong> &lt;code>legacy&lt;/code> with direct OTel writes, &lt;code>events_only&lt;/code> without the preview opt-in, and &lt;code>events_only&lt;/code> with OTel behaviour set to dual. The code comment names them: &lt;em>combinations that would silently lose data&lt;/em> (&lt;code>worker/src/env.ts:722&lt;/code>).&lt;/p>
&lt;p>&lt;strong>In &lt;code>events_only&lt;/code> twenty-four REST routes return 404.&lt;/strong> Traces, observations, sessions, v1 and v2 scores, metrics, spans, generations and the dataset-run ones. Not a bug: a deliberate short circuit so stale data from tables nobody writes is never served.&lt;/p>
&lt;p>&lt;strong>History is backfilled by a chain of five background migrations&lt;/strong>, not by the ClickHouse migrations. Not one of the ten new ClickHouse migrations copies a single old row.&lt;/p>
&lt;p>&lt;strong>A failed background migration does not block the next one.&lt;/strong> The manager has no dependency model and skips them. The chain protects itself with predecessor guards that &lt;strong>the first two steps do not declare&lt;/strong>, so a failure of step 1 does not stop step 2 from starting.&lt;/p>
&lt;p>&lt;strong>The old API&amp;rsquo;s ten-minute delay has an exact explanation.&lt;/strong> Three-minute partitions in the staging table, a lock four minutes after creation, and a per-minute cron that only touches partitions older than ten minutes, with a global concurrency of one.&lt;/p>
&lt;p>&lt;strong>That staging table has a forty-eight-hour TTL.&lt;/strong> If the propagator stops over a long weekend, those partitions disappear and the hole in &lt;code>events_full&lt;/code> is permanent. No code detects it and none repairs it.&lt;/p>
&lt;p>&lt;strong>The point of no return is in Postgres, not in ClickHouse.&lt;/strong> A Prisma migration runs &lt;code>DROP TABLE&lt;/code> on &lt;code>traces&lt;/code>, &lt;code>observations&lt;/code> and &lt;code>scores&lt;/code>, and Prisma Migrate has no down migrations.&lt;/p>
&lt;h2 id="you-are-here-observe-day-2">You are here: OBSERVE, day 2&lt;/h2>
&lt;p>The first three articles measured what an agent produces. This one starts on operating the backend that receives it, and it comes before the capacity article because you cannot size what has not been migrated yet.&lt;/p>
&lt;h2 id="the-analogy-changing-the-track-gauge-without-closing-the-line">The analogy: changing the track gauge without closing the line&lt;/h2>
&lt;p>Changing the gauge of a line in service is not done in one go. The third rail is laid, rolling stock of both gauges runs for a while, and only when every train is of the new gauge is the old rail lifted.&lt;/p>
&lt;p>The Langfuse migration has exactly that shape. &lt;code>dual&lt;/code> mode is the third rail: the old tables and the new ones are written at the same time. And as with the track, the danger is not the day of the change, it is at both ends. Laying the new rail and lifting the old one on the same day strands trains. And once the old rail is up, going back is not a matter of will, it is a matter of the material no longer being there.&lt;/p>
&lt;h2 id="the-three-rung-ladder">The three-rung ladder&lt;/h2>
&lt;p>Everything turns on one variable with three values, defined three times over in the three packages that have to agree, with the same enum and the same default (&lt;code>packages/shared/src/env.ts:337&lt;/code>, &lt;code>worker/src/env.ts:586&lt;/code>, &lt;code>web/src/env.mjs:610&lt;/code>):&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-ts" data-lang="ts">&lt;span class="line">&lt;span class="cl">&lt;span class="nx">LANGFUSE_MIGRATION_V4_WRITE_MODE&lt;/span>: &lt;span class="kt">z&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">.&lt;/span>&lt;span class="kr">enum&lt;/span>&lt;span class="p">([&lt;/span>&lt;span class="s2">&amp;#34;legacy&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s2">&amp;#34;dual&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s2">&amp;#34;events_only&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="k">default&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;events_only&amp;#34;&lt;/span>&lt;span class="p">),&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>The two helpers that read it say what each rung does better than any explanation (&lt;code>worker/src/env.ts:705&lt;/code>):&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-ts" data-lang="ts">&lt;span class="line">&lt;span class="cl">&lt;span class="kr">export&lt;/span> &lt;span class="kr">const&lt;/span> &lt;span class="nx">v4WritesToEventsTable&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="nx">envValue&lt;/span>: &lt;span class="kt">ParsedEnv&lt;/span>&lt;span class="p">)&lt;/span>&lt;span class="o">:&lt;/span> &lt;span class="kr">boolean&lt;/span> &lt;span class="o">=&amp;gt;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nx">envValue&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">LANGFUSE_MIGRATION_V4_WRITE_MODE&lt;/span> &lt;span class="o">!==&lt;/span> &lt;span class="s2">&amp;#34;legacy&amp;#34;&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="kr">export&lt;/span> &lt;span class="kr">const&lt;/span> &lt;span class="nx">v4WritesToLegacyTables&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="nx">envValue&lt;/span>: &lt;span class="kt">ParsedEnv&lt;/span>&lt;span class="p">)&lt;/span>&lt;span class="o">:&lt;/span> &lt;span class="kr">boolean&lt;/span> &lt;span class="o">=&amp;gt;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nx">envValue&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">LANGFUSE_MIGRATION_V4_WRITE_MODE&lt;/span> &lt;span class="o">!==&lt;/span> &lt;span class="s2">&amp;#34;events_only&amp;#34;&lt;/span>&lt;span class="p">;&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>What gets written in each mode:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Table&lt;/th>
&lt;th>&lt;code>legacy&lt;/code>&lt;/th>
&lt;th>&lt;code>dual&lt;/code>&lt;/th>
&lt;th>&lt;code>events_only&lt;/code>&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>&lt;code>traces&lt;/code>, &lt;code>observations&lt;/code>&lt;/td>
&lt;td>yes&lt;/td>
&lt;td>yes&lt;/td>
&lt;td>&lt;strong>no&lt;/strong>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>scores&lt;/code>&lt;/td>
&lt;td>yes&lt;/td>
&lt;td>yes&lt;/td>
&lt;td>yes&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>dataset_run_items_rmt&lt;/code>&lt;/td>
&lt;td>yes&lt;/td>
&lt;td>yes&lt;/td>
&lt;td>&lt;strong>no&lt;/strong>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>observations_batch_staging&lt;/code>&lt;/td>
&lt;td>no&lt;/td>
&lt;td>yes&lt;/td>
&lt;td>no&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>events_full&lt;/code>&lt;/td>
&lt;td>&lt;strong>no&lt;/strong>&lt;/td>
&lt;td>yes&lt;/td>
&lt;td>yes&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>events_core&lt;/code>&lt;/td>
&lt;td>no&lt;/td>
&lt;td>yes (through the materialised view)&lt;/td>
&lt;td>yes&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>The middle rung is the only one where both coexist, and therefore the only place you can step back from without losing anything. The code itself offers it as the remedy when something breaks in &lt;code>events_only&lt;/code> (&lt;code>web/src/pages/api/public/ingestion.ts:296&lt;/code>):&lt;/p>
&lt;blockquote>
&lt;p>As a temporary migration bridge, set &lt;code>LANGFUSE_MIGRATION_V4_WRITE_MODE=dual&lt;/code> on both the web and worker services and redeploy.&lt;/p>
&lt;/blockquote>
&lt;p>The &lt;em>both&lt;/em> in that sentence matters. All three packages read the variable and the code comment insists three separate times on keeping them in sync. Deploying the worker with one value and the web with another is the first way to break this.&lt;/p>
&lt;h2 id="the-three-combinations-that-refuse-to-start">The three combinations that refuse to start&lt;/h2>
&lt;p>The worker validates its configuration at boot and throws in three cases, under a comment that leaves no doubt (&lt;code>worker/src/env.ts:718&lt;/code>):&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-ts" data-lang="ts">&lt;span class="line">&lt;span class="cl"> &lt;span class="c1">// Hard errors: combinations that would silently lose data.
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1">&lt;/span> &lt;span class="k">if&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="nx">mode&lt;/span> &lt;span class="o">===&lt;/span> &lt;span class="s2">&amp;#34;legacy&amp;#34;&lt;/span> &lt;span class="o">&amp;amp;&amp;amp;&lt;/span> &lt;span class="nx">otel&lt;/span> &lt;span class="o">===&lt;/span> &lt;span class="s2">&amp;#34;direct&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="k">throw&lt;/span> &lt;span class="k">new&lt;/span> &lt;span class="nb">Error&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;Invalid V4 config: LANGFUSE_MIGRATION_V4_NATIVE_OTEL_BEHAVIOUR=direct &amp;#34;&lt;/span> &lt;span class="o">+&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="s2">&amp;#34;requires LANGFUSE_MIGRATION_V4_WRITE_MODE in {dual, events_only}. &amp;#34;&lt;/span> &lt;span class="o">+&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="s2">&amp;#34;Direct OTel writes target events_full, which is not read in legacy mode.&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="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>The second one bites in a real migration: &lt;code>events_only&lt;/code> also requires &lt;code>LANGFUSE_MIGRATION_V4_ALLOW_PREVIEW_OPT_IN=true&lt;/code>, because otherwise the web reads would target the legacy tables that mode no longer writes. The third forbids &lt;code>events_only&lt;/code> with OTel behaviour set to dual.&lt;/p>
&lt;p>A failed boot is the good news. A failed boot shows up in thirty seconds. The combinations it does not validate take days to surface.&lt;/p>
&lt;h2 id="what-breaks-in-each-mode">What breaks in each mode&lt;/h2>
&lt;p>This is the table to have in front of you before moving the variable, because the two ends break different things.&lt;/p>
&lt;p>&lt;strong>In &lt;code>legacy&lt;/code>&lt;/strong> the event tables are not written, so whatever depends on them disappears. Monitor routes return 404 through a dedicated middleware (&lt;code>web/src/server/api/trpc.ts:413&lt;/code>). Metrics v2 is unavailable. Dashboard widgets stay at version 1 (&lt;code>DashboardService.ts:39&lt;/code>). The v4 preview is forced off and cannot be switched on.&lt;/p>
&lt;p>&lt;strong>In &lt;code>events_only&lt;/code>&lt;/strong> the legacy tables are not written, so twenty-four REST routes short-circuit with a 404 (&lt;code>web/src/features/public-api/server/createAuthedProjectAPIRoute.ts:319&lt;/code>):&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-ts" data-lang="ts">&lt;span class="line">&lt;span class="cl"> &lt;span class="c1">// Short-circuit routes that read from legacy traces/observations tables
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1">&lt;/span> &lt;span class="c1">// when the deployment is in events_only mode — those tables are no longer
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1">&lt;/span> &lt;span class="c1">// populated, so the response would be stale or empty.
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>The list covers &lt;code>traces&lt;/code>, &lt;code>traces/{id}&lt;/code>, &lt;code>observations&lt;/code>, &lt;code>observations/{id}&lt;/code>, &lt;code>sessions&lt;/code>, &lt;code>sessions/{id}&lt;/code>, &lt;code>scores&lt;/code> and &lt;code>scores/{id}&lt;/code> in v1 and v2, &lt;code>metrics&lt;/code>, &lt;code>metrics/daily&lt;/code>, &lt;code>spans&lt;/code>, &lt;code>generations&lt;/code>, &lt;code>events&lt;/code>, &lt;code>dataset-run-items&lt;/code> and the two &lt;code>datasets/{name}/runs&lt;/code> routes. If you have your own integrations against those routes, they break on cutover day, and returning 404 rather than stale data is the right call even though it stings.&lt;/p>
&lt;p>One detail that is usually reported wrongly: &lt;strong>&lt;code>/api/public/ingestion&lt;/code> is never shut down&lt;/strong>. The deprecation notice says so itself (&lt;code>deprecations.ts:104&lt;/code>):&lt;/p>
&lt;blockquote>
&lt;p>This route is never removed: scores (and sdk-log) stay accepted.&lt;/p>
&lt;/blockquote>
&lt;p>In &lt;code>events_only&lt;/code> that route stays alive and keeps accepting two event types (&lt;code>web/src/pages/api/public/ingestion.ts:274&lt;/code>):&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-ts" data-lang="ts">&lt;span class="line">&lt;span class="cl">&lt;span class="kr">const&lt;/span> &lt;span class="nx">EVENTS_ONLY_ALLOWED_TYPES&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="k">new&lt;/span> &lt;span class="nx">Set&lt;/span>&lt;span class="p">&amp;lt;&lt;/span>&lt;span class="nt">string&lt;/span>&lt;span class="p">&amp;gt;([&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nx">eventTypes&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">SCORE_CREATE&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nx">eventTypes&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">SDK_LOG&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>The rest are rejected &lt;strong>per event, not per batch&lt;/strong>: the batch comes back as a 207 with 400s inside. An old client sending traces and scores mixed together will see the scores go through and the rest fail, which is exactly the kind of partial failure that takes a long time to notice.&lt;/p>
&lt;h2 id="the-three-steps-in-order">The three steps, in order&lt;/h2>
&lt;p>Given all of the above, the order of a no-window migration is this:&lt;/p>
&lt;ol>
&lt;li>&lt;strong>Finish the v3 background migrations before upgrading.&lt;/strong> This comes before everything else and there are no shortcuts. v4 deletes the rows of seven old migrations, and the comment explains why (&lt;code>prisma/migrations/20260723124010_drop_dataset_run_items_table/migration.sql:7&lt;/code>): their worker scripts are deleted alongside, so an unfinished row would leave the manager with no script to resolve. If one was halfway, that data is lost and the row disappears quietly.&lt;/li>
&lt;li>&lt;strong>Upgrade to v4 with &lt;code>LANGFUSE_MIGRATION_V4_WRITE_MODE=legacy&lt;/code>&lt;/strong>, apply the ClickHouse and Prisma migrations. Here the installation behaves like v3 and you verify everything still stands.&lt;/li>
&lt;li>&lt;strong>Move to &lt;code>dual&lt;/code>.&lt;/strong> From then on both table families are written and the propagator starts. Let it run for as long as it takes and launch the historic backfill.&lt;/li>
&lt;li>&lt;strong>Once history is complete and verified, move to &lt;code>events_only&lt;/code>&lt;/strong> with the preview opt-in enabled, and only then lift the old rail.&lt;/li>
&lt;/ol>
&lt;p>All the work is between 3 and 4, and it can take weeks. There is no rush: &lt;code>dual&lt;/code> is a stable state.&lt;/p>
&lt;h2 id="the-clickhouse-migrations-backfill-nothing">The ClickHouse migrations backfill nothing&lt;/h2>
&lt;p>There are 96 files in &lt;code>packages/shared/clickhouse/migrations/canonical/&lt;/code>, 48 up and 48 down, which have to be materialised before running (&lt;code>pnpm ch:migrations:materialize&lt;/code>) and cleaned afterwards, including after a failed migration. The runner is golang-migrate.&lt;/p>
&lt;p>The ten v4 ones are 0039 through 0048. The first three create &lt;code>events_full&lt;/code>, &lt;code>events_core&lt;/code> and the materialised view that feeds the second from the first. The three drop migrations remove &lt;code>event_log&lt;/code>, &lt;code>project_environments&lt;/code> and &lt;code>dataset_run_items&lt;/code>. The ones in between add columns and indexes.&lt;/p>
&lt;p>What matters for planning is what they do &lt;strong>not&lt;/strong> do. None copies an old row. The materialised view only sees rows inserted into &lt;code>events_full&lt;/code> after it exists. The indexes in migrations 0043 and 0047 are added without materialising historical parts, and 0047 says so in writing:&lt;/p>
&lt;blockquote>
&lt;p>Skip indexes are added without materializing historical parts to keep this migration metadata-only.&lt;/p>
&lt;/blockquote>
&lt;p>Translated into operations: during and after the migration, searches over older data do full scans. Not a bug, the price of a migration that does not block.&lt;/p>
&lt;p>And one cluster trap: migrations 0042, 0043, 0047 and 0048 carry &lt;code>alter_sync = 2&lt;/code>, meaning every &lt;code>ALTER&lt;/code> &lt;strong>waits for all replicas&lt;/strong>. One slow or down replica hangs the whole migration. On a single-replica installation you never notice; on three, you do.&lt;/p>
&lt;h2 id="the-five-step-chain-that-does-backfill-history">The five-step chain that does backfill history&lt;/h2>
&lt;p>The backfill is done by five background migrations running in the worker, registered in Postgres with names that carry the order inside them:&lt;/p>
&lt;ol>
&lt;li>
&lt;p>&lt;code>20260701_v4_step_1_create_root_spans_from_traces&lt;/code>: creates virtual root spans in &lt;code>events_full&lt;/code> from existing traces, with the identifier &lt;code>concat('t-', t.id)&lt;/code>. It skips traces referenced by a dataset run item, which belong to step 4. Chunked by monthly partition.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;code>20260701_v4_step_2_rewrite_observations_to_pid_tid_sorting&lt;/code>: copies &lt;code>observations&lt;/code> into a scratch table with a different key order. Chunked by monthly partition.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;code>20260701_v4_step_3_backfill_events_full_from_observations&lt;/code>: the bulk of it. Chunked &lt;strong>by ClickHouse part&lt;/strong>, not by partition, and the comment explains why that is the only safe granularity:&lt;/p>
&lt;blockquote>
&lt;p>One todo per part keeps each INSERT bounded to a single ClickHouse part (single-digit GBs in most cases), which is the only granularity that&amp;rsquo;s safe to assume self-hoster hardware can chew through without OOM or memory-limit failures.&lt;/p>
&lt;/blockquote>
&lt;/li>
&lt;li>
&lt;p>&lt;code>20260701_v4_step_4_backfill_events_full_from_dataset_run_items&lt;/code>: walks the dataset run items by cursor and writes the root and its descendants. Batches of 200, a cap of 50,000 descendants per item, a 90-day window.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;code>20260701_v4_step_5_drop_pid_tid_sorting_tables&lt;/code>: drops step 2&amp;rsquo;s scratch tables.&lt;/p>
&lt;/li>
&lt;/ol>
&lt;p>There are only &lt;strong>two switches&lt;/strong> for the five. The first four share &lt;code>LANGFUSE_BACKGROUND_MIGRATION_V4_ENABLE_HISTORIC_BACKFILL&lt;/code>, which ships &lt;code>true&lt;/code>, and the fifth has its own, &lt;code>LANGFUSE_BACKGROUND_MIGRATION_V4_DROP_PID_TID_SORTING_TABLES&lt;/code>, which ships &lt;code>false&lt;/code> on purpose so the scratch tables stay around until the operator confirms the backfill went well.&lt;/p>
&lt;p>&lt;strong>One migration at a time across the whole cluster.&lt;/strong> The manager takes a lock in Postgres, refreshes it with a heartbeat every 15 seconds and treats a lock older than 60 seconds as expired, all inside a serialisable transaction. Adding worker replicas does not speed the backfill up by one minute. That is the first thing that surprises anyone used to scaling out to go faster.&lt;/p>
&lt;p>&lt;strong>They are resumable, and rather better than this usually is.&lt;/strong> Progress lives in a JSONB field on the row, and on resume the worker &lt;strong>re-attaches to the queries the previous worker left running&lt;/strong>, by query id, instead of relaunching them:&lt;/p>
&lt;blockquote>
&lt;p>In-flight queries keep running server-side; the next run re-attaches to them via the persisted queryIds.&lt;/p>
&lt;/blockquote>
&lt;p>On how long they take, the code says nothing. Not the files, not the folder&amp;rsquo;s README, not the migrations. The only time threshold anywhere is a design one: &lt;em>&amp;ldquo;A good threshold is something that takes more than 5 minutes to run&amp;rdquo;&lt;/em>. Any figure in hours or days you read elsewhere, including the one I repeated myself before checking, is not in the code.&lt;/p>
&lt;h2 id="the-failure-that-does-not-block">The failure that does not block&lt;/h2>
&lt;p>This is the finding that changes a runbook the most. When a background migration fails, the manager marks it with &lt;code>failedAt&lt;/code> and moves on. And the predicate it uses to pick the next one excludes exactly the rows carrying &lt;code>failedAt&lt;/code>, so &lt;strong>a failed migration is skipped and does not block the ones after it&lt;/strong>.&lt;/p>
&lt;p>The code acknowledges the gap and explains the patch (&lt;code>worker/src/backgroundMigrations/utils/backfillBase.ts:107&lt;/code>):&lt;/p>
&lt;blockquote>
&lt;p>The &lt;code>BackgroundMigrationManager&lt;/code> has no dependency model — it runs every eligible row in name order and marks each finished/failed independently, so a failed upstream step does NOT by itself stop a downstream step from running on partial data. Each downstream step calls this from its &lt;code>validate()&lt;/code> [&amp;hellip;] Because the next step in turn guards on its own predecessor, a single failure transitively halts the rest of the chain.&lt;/p>
&lt;/blockquote>
&lt;p>The guard exists, but &lt;strong>only in steps 3 and 4&lt;/strong>. Steps 1 and 2 declare no predecessor. A failure of step 1 does not stop step 2 from starting; what saves the situation is that step 3 does check step 2.&lt;/p>
&lt;p>Practical consequence: the only reliable signal that the backfill is going well is to look at the &lt;code>background_migrations&lt;/code> table in Postgres and check that all five rows have &lt;code>finishedAt&lt;/code> and none has &lt;code>failedAt&lt;/code>. And to restart a failed one you clear &lt;code>failedAt&lt;/code> by hand and relaunch with &lt;code>--retry-failed&lt;/code>, as the error message itself says.&lt;/p>
&lt;p>Two concrete failure modes that deserve a place in the runbook:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Step 3 breaks if ClickHouse merges a part midway.&lt;/strong> The message says it all: &lt;em>&amp;ldquo;Part &amp;hellip; no longer active after processing — its rows are in a merged successor part that is not in this run&amp;rsquo;s todo list. Clear failedAt and re-run with state.chunksLoaded=false&amp;rdquo;&lt;/em>. Recovery is manual, editing the JSONB.&lt;/li>
&lt;li>&lt;strong>Step 4 stops dead on one giant trace&lt;/strong>, if it exceeds 50,000 descendants. The three ways out the error offers are raising the cap, skipping the item (and losing that trace entirely) or fixing it by hand in SQL.&lt;/li>
&lt;/ul>
&lt;p>And an asymmetry worth knowing before comparing data: the historic backfill &lt;strong>does not copy trace metadata down to child spans&lt;/strong>, deliberately, whereas the live path does concatenate them. History and new data are not identical.&lt;/p>
&lt;h2 id="where-the-ten-minutes-come-from">Where the ten minutes come from&lt;/h2>
&lt;p>The v3 API warns that its data runs about ten minutes behind (&lt;code>deprecations.ts:16&lt;/code>):&lt;/p>
&lt;blockquote>
&lt;p>Data on this API is delayed by about 10 minutes. Real-time is only OpenTelemetry writes plus v2 observations and v2 metrics reads.&lt;/p>
&lt;/blockquote>
&lt;p>That delay is the sum of four things, all visible in the code:&lt;/p>
&lt;ul>
&lt;li>The staging table partitions on &lt;strong>three-minute&lt;/strong> intervals.&lt;/li>
&lt;li>A partition is treated as closed &lt;strong>four minutes&lt;/strong> after creation: &lt;em>&amp;ldquo;We &amp;rsquo;lock&amp;rsquo; partitions 4min after their creation, i.e. the 15:00:00 partition should stop receiving updates at 15:04:00.&amp;rdquo;&lt;/em>&lt;/li>
&lt;li>The propagation cron runs &lt;strong>every minute&lt;/strong> and only looks at partitions older than &lt;code>LANGFUSE_EXPERIMENT_EVENT_PROPAGATION_PARTITION_DELAY_MINUTES&lt;/code>, which defaults to &lt;strong>10&lt;/strong>.&lt;/li>
&lt;li>That cron has a &lt;strong>global concurrency of one&lt;/strong> across the cluster and processes a single partition per invocation, so a backlog makes the delay grow rather than recover.&lt;/li>
&lt;/ul>
&lt;p>The way out of the delay is not to lower that variable, it is to have clients write over OTLP with direct writes, which is the real-time route. The header that enables it is &lt;code>x-langfuse-ingestion-version: 4&lt;/code>, sent automatically by the Python SDK from 4.0.0 and the JavaScript one from 5.0.0, and a value above 4 is rejected with a 400.&lt;/p>
&lt;h2 id="the-forty-eight-hour-ttl">The forty-eight-hour TTL&lt;/h2>
&lt;p>This is the frightening one, and it appears in no migration guide.&lt;/p>
&lt;p>The staging table carries &lt;code>TTL toDateTime(s3_first_seen_timestamp) + INTERVAL 48 HOUR&lt;/code> with &lt;code>ttl_only_drop_parts = 1&lt;/code>. If the propagator stops for more than forty-eight hours, a long bank holiday for instance, those partitions delete themselves. The data is already in the legacy tables, so an installation in &lt;code>dual&lt;/code> keeps working, but the hole in &lt;code>events_full&lt;/code> is &lt;strong>permanent&lt;/strong>, and no code detects it and none repairs it. The health probe detects &lt;em>stuck&lt;/em>, not &lt;em>lost&lt;/em>.&lt;/p>
&lt;p>The defence is an alert, and one exists for this. The worker health endpoint accepts &lt;code>?failIfEventPropagationStuck=true&lt;/code> and returns 503 if the propagator&amp;rsquo;s heartbeat has not been refreshed for more than &lt;code>LANGFUSE_EVENT_PROPAGATION_STUCK_THRESHOLD_MINUTES&lt;/code>, which defaults to 35. The comment carries a condition to read before putting it in a Kubernetes probe:&lt;/p>
&lt;blockquote>
&lt;p>Probes using this flag MUST set &lt;code>initialDelaySeconds &amp;gt;= 60s&lt;/code> (one cron cycle) [&amp;hellip;] A shorter delay can crash-loop the very restart this check triggers.&lt;/p>
&lt;/blockquote>
&lt;p>Another detail of the same mechanism: the propagation cursor lives &lt;strong>in Redis&lt;/strong>, not in Postgres. If Redis is flushed, the cursor resets and every live partition of the staging table is reprocessed.&lt;/p>
&lt;h2 id="what-can-and-cannot-be-undone">What can and cannot be undone&lt;/h2>
&lt;p>The ClickHouse down migrations exist, and they are honest about what they do. The one for 0039 warns in capitals:&lt;/p>
&lt;blockquote>
&lt;p>WARNING: destructive rollback. The up migration uses IF NOT EXISTS, so this table may predate this migration [&amp;hellip;] Rolling back drops that data regardless of which path created the table.&lt;/p>
&lt;/blockquote>
&lt;p>The ones for the dropped tables recreate the schema and nothing else: &lt;em>&amp;ldquo;Any data held in event_log before the up migration dropped it is NOT restored.&amp;rdquo;&lt;/em>&lt;/p>
&lt;p>But the real point of no return is not in ClickHouse, it is in Postgres. There is a Prisma migration called &lt;code>20260723150000_drop_legacy_tracing_tables&lt;/code> that does exactly what it says:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-sql" data-lang="sql">&lt;span class="line">&lt;span class="cl">&lt;span class="k">DROP&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">TABLE&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">IF&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">EXISTS&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;traces&amp;#34;&lt;/span>&lt;span class="p">;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="k">DROP&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">TABLE&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">IF&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">EXISTS&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;observations&amp;#34;&lt;/span>&lt;span class="p">;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="k">DROP&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">TABLE&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">IF&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">EXISTS&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;scores&amp;#34;&lt;/span>&lt;span class="p">;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Prisma Migrate has no down migrations. It applies, and that is that.&lt;/p>
&lt;p>Summarising the real reversibility: the write mode can go down from &lt;code>events_only&lt;/code> to &lt;code>dual&lt;/code> whenever you want, and that is the panic button. What does not come back is what was not written while you were up there: the legacy tables receive nothing retroactively. And step 5 of the chain, the one that drops the scratch tables, ships disabled precisely so you can do forensics if something went wrong.&lt;/p>
&lt;p>One last note on the ground you are standing on. The comment in the export-source policy drops something worth reading twice: &lt;em>&amp;ldquo;legacy-writes-disabled, which in practice only surfaces on self-hosted (Cloud does not run events_only)&amp;rdquo;&lt;/em>. The &lt;code>events_only&lt;/code> path is the one you will use and not the one the vendor&amp;rsquo;s managed service uses. It is less battle-tested in production than &lt;code>dual&lt;/code>.&lt;/p>
&lt;h2 id="how-this-looks-in-code">How this looks in code&lt;/h2>
&lt;p>The minimum runbook, in variables:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># Step 2: upgrade to v4 behaving like v3&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="nv">LANGFUSE_MIGRATION_V4_WRITE_MODE&lt;/span>&lt;span class="o">=&lt;/span>legacy
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="nv">LANGFUSE_MIGRATION_V4_NATIVE_OTEL_BEHAVIOUR&lt;/span>&lt;span class="o">=&lt;/span>dual_write &lt;span class="c1"># &amp;#34;direct&amp;#34; will not start here&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="nv">LANGFUSE_BACKGROUND_MIGRATION_V4_ENABLE_HISTORIC_BACKFILL&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="nb">false&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"># Step 3: third rail. Both services, web and worker, together.&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="nv">LANGFUSE_MIGRATION_V4_WRITE_MODE&lt;/span>&lt;span class="o">=&lt;/span>dual
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="nv">LANGFUSE_BACKGROUND_MIGRATION_V4_ENABLE_HISTORIC_BACKFILL&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="nb">true&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="nv">LANGFUSE_EVENT_PROPAGATION_STUCK_THRESHOLD_MINUTES&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="m">35&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"># Step 4: only once all five background_migrations rows have finishedAt&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="nv">LANGFUSE_MIGRATION_V4_WRITE_MODE&lt;/span>&lt;span class="o">=&lt;/span>events_only
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="nv">LANGFUSE_MIGRATION_V4_ALLOW_PREVIEW_OPT_IN&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="nb">true&lt;/span> &lt;span class="c1"># without this it will not start&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="nv">LANGFUSE_BACKGROUND_MIGRATION_V4_DROP_PID_TID_SORTING_TABLES&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="nb">true&lt;/span> &lt;span class="c1"># last of all&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>And the query that answers the only question that matters for weeks:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-sql" data-lang="sql">&lt;span class="line">&lt;span class="cl">&lt;span class="k">SELECT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">name&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">started_at&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">finished_at&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">failed_at&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">failed_reason&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="k">state&lt;/span>&lt;span class="o">-&amp;gt;&amp;gt;&lt;/span>&lt;span class="s1">&amp;#39;chunksLoaded&amp;#39;&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">AS&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">chunks&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="k">FROM&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">background_migrations&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="k">WHERE&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">name&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">LIKE&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s1">&amp;#39;20260701_v4_%&amp;#39;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="k">ORDER&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">BY&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">name&lt;/span>&lt;span class="p">;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h2 id="migration-checklist">Migration checklist&lt;/h2>
&lt;ul>
&lt;li>Every v3 background migration is finished before the image is upgraded.&lt;/li>
&lt;li>There is a Postgres and a ClickHouse backup from before the first &lt;code>DROP TABLE&lt;/code>.&lt;/li>
&lt;li>The write mode has the same value in the worker and in the web, and both change together.&lt;/li>
&lt;li>The upgrade went to &lt;code>legacy&lt;/code> first, and the service was verified unchanged.&lt;/li>
&lt;li>There is an alert on &lt;code>?failIfEventPropagationStuck=true&lt;/code>, with &lt;code>initialDelaySeconds&lt;/code> of at least 60 seconds.&lt;/li>
&lt;li>Somebody looks at the &lt;code>background_migrations&lt;/code> table every day while the backfill runs.&lt;/li>
&lt;li>The in-house integrations calling the twenty-four routes that will return 404 have been inventoried.&lt;/li>
&lt;li>Client SDKs are on Python 4.0.0 or JavaScript 5.0.0 at minimum before moving to &lt;code>events_only&lt;/code>.&lt;/li>
&lt;li>Step 5 of the chain is enabled last, and only after verifying the backfill.&lt;/li>
&lt;/ul>
&lt;h2 id="traps">Traps&lt;/h2>
&lt;p>&lt;strong>The default is the end state.&lt;/strong> A v3 installation booting v4 without touching the variable lands in &lt;code>events_only&lt;/code> and stops writing the old tables from the first second.&lt;/p>
&lt;p>&lt;strong>A failed background migration does not block the next one.&lt;/strong> And the first two steps of the chain declare no predecessor.&lt;/p>
&lt;p>&lt;strong>More worker replicas do not speed up the backfill.&lt;/strong> One migration at a time across the cluster, by design.&lt;/p>
&lt;p>&lt;strong>Forty-eight hours of stopped propagator is a permanent hole.&lt;/strong> No detection and no repair.&lt;/p>
&lt;p>&lt;strong>New indexes do not cover old data.&lt;/strong> Historical searches scan until somebody materialises the indexes by hand.&lt;/p>
&lt;p>&lt;strong>On a cluster, every &lt;code>ALTER&lt;/code> waits for all replicas.&lt;/strong> One down replica hangs the migration.&lt;/p>
&lt;p>&lt;strong>Classic ingestion rejects per event.&lt;/strong> A mixed batch returns 207 and half of it is lost without anyone reading the response body.&lt;/p>
&lt;p>&lt;strong>History and the live path do not produce quite the same thing.&lt;/strong> Trace metadata does not descend to children in the backfill.&lt;/p>
&lt;p>&lt;strong>The route you will take is not the one the vendor takes.&lt;/strong> Cloud does not run &lt;code>events_only&lt;/code>.&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>&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>Migrating from version 3 to 4 with no window (this article): 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 this migration leads to.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/langfuse-inside-sorting-centre-bottleneck/">Langfuse from the inside&lt;/a>: the version 3 architecture, which is what you are leaving.&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>: what happens to the gateway during the change.&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>: the volume the migrated installation will have to swallow.&lt;/li>
&lt;/ul>
&lt;h2 id="sources">Sources&lt;/h2>
&lt;ul>
&lt;li>Langfuse 4.35.0 source, commit &lt;code>2ee1908&lt;/code> of 14 September 2026: &lt;code>packages/shared/src/env.ts&lt;/code>, &lt;code>worker/src/env.ts&lt;/code>, &lt;code>web/src/env.mjs&lt;/code>, &lt;code>web/src/features/public-api/server/deprecations.ts&lt;/code>, &lt;code>web/src/pages/api/public/ingestion.ts&lt;/code>, &lt;code>web/src/features/public-api/server/createAuthedProjectAPIRoute.ts&lt;/code>, &lt;code>worker/src/backgroundMigrations/&lt;/code>, &lt;code>worker/src/features/eventPropagation/&lt;/code>, &lt;code>packages/shared/clickhouse/migrations/canonical/&lt;/code>, &lt;code>packages/shared/prisma/migrations/&lt;/code>.&lt;/li>
&lt;li>&lt;a href="https://langfuse.com/docs/compatibility">Langfuse compatibility and migration&lt;/a>, checked on 14 September 2026.&lt;/li>
&lt;li>&lt;a href="https://langfuse.com/integrations/native/opentelemetry">OpenTelemetry integration migration to v4&lt;/a>, checked on 14 September 2026.&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><item><title>Langfuse v4, day 2 (1 of 10): the data model changed underneath, and what really goes into a trace</title><link>https://blog.lo0.es/en/posts/langfuse-v4-what-goes-into-a-trace/</link><pubDate>Sat, 12 Sep 2026 07:00:00 +0200</pubDate><guid>https://blog.lo0.es/en/posts/langfuse-v4-what-goes-into-a-trace/</guid><description>&lt;blockquote>
&lt;p>First article in a series of ten about operating Langfuse v4 in production. The architecture of version 3 and its ten performance levers were already covered in &lt;a href="https://blog.lo0.es/en/posts/langfuse-inside-sorting-centre-bottleneck/">Langfuse from the inside&lt;/a>; the seam with the gateway, in &lt;a href="https://blog.lo0.es/en/posts/litellm-langfuse-operational-pair/">the operational pair&lt;/a>. This series is about day 2. Verified against Langfuse 4.35.0, commit of 11 September 2026.&lt;/p>
&lt;/blockquote>
&lt;h2 id="tldr">TL;DR&lt;/h2>
&lt;p>&lt;strong>Version 4 is observations first.&lt;/strong> There is no trace table. A trace is a set of observations that share an identifier, and the trace attributes live denormalised in every row: name, user, session, tags, version, release and environment (&lt;code>clickhouse/migrations/canonical/0039_create_events_full.up.sql&lt;/code>). The whole intuition of &amp;ldquo;first I create the trace and then I hang things off it&amp;rdquo; stops describing the storage.&lt;/p>
&lt;p>&lt;strong>There are ten observation types, not three.&lt;/strong> Alongside the classic &lt;code>SPAN&lt;/code>, &lt;code>EVENT&lt;/code> and &lt;code>GENERATION&lt;/code> come &lt;code>AGENT&lt;/code>, &lt;code>TOOL&lt;/code>, &lt;code>CHAIN&lt;/code>, &lt;code>RETRIEVER&lt;/code>, &lt;code>EVALUATOR&lt;/code>, &lt;code>EMBEDDING&lt;/code> and &lt;code>GUARDRAIL&lt;/code> (&lt;code>packages/shared/src/domain/observations.ts:5&lt;/code>). Using the right type changes what the interface knows how to group.&lt;/p>
&lt;p>&lt;strong>The real limits are in the code and they do not match what people assume.&lt;/strong> The body of a request to the classic ingestion route is capped at &lt;strong>4.5 MB&lt;/strong> (&lt;code>api/public/ingestion.ts:40&lt;/code>). The OTLP body, at &lt;strong>512 MiB&lt;/strong>, compressed and decompressed, with a 413 when it is exceeded. An OTLP span above 9.5 MB is only written to the log, not rejected. Identifiers accept 800 characters. The trace name, 1,000.&lt;/p>
&lt;p>&lt;strong>The ingester looks for the user in five places and the session in six.&lt;/strong> With strict precedence order, starting from Langfuse&amp;rsquo;s own attributes and ending in the metadata of other frameworks. Among the session ones sits &lt;code>gen_ai.conversation.id&lt;/code>, which is the OpenTelemetry standard, so a conformant instrumentation works without translation.&lt;/p>
&lt;p>&lt;strong>Scores do not travel over OTLP.&lt;/strong> Zero occurrences of the word across the 3,864 lines of the OTLP ingestion processor. They come in through the classic ingestion route, through the scores endpoint or through the interface. Any design that assumes an evaluator can write its score down the same channel as the trace is wrong.&lt;/p>
&lt;p>&lt;strong>The classic masking function does not cover what almost everyone thinks it covers.&lt;/strong> It only acts on data that passes through the SDK calls. The attributes produced by a third-party automatic instrumentation come out unmasked. There is a new function that does act on the batch attributes, but it leaves out span events, which is where the recent conventions put the messages.&lt;/p>
&lt;p>&lt;strong>Filtering by tags or by status message is a scan.&lt;/strong> In the listings table, the indexed fields are the span identifier, the trace identifier, the user identifier, the session identifier, the dates, the model and the metadata names. The name, the tags, the version and the status message have no index.&lt;/p>
&lt;h2 id="you-are-here-observe-day-2">You are here: OBSERVE, day 2&lt;/h2>
&lt;p>The previous series about this pair covered selection and setup. The &lt;a href="https://blog.lo0.es/en/posts/langfuse-inside-sorting-centre-bottleneck/">six-service architecture&lt;/a> explained version 3; &lt;a href="https://blog.lo0.es/en/posts/llm-tracing-opentelemetry-genai/">tracing with OpenTelemetry&lt;/a> explained the pipeline; the &lt;a href="https://blog.lo0.es/en/posts/litellm-langfuse-operational-pair/">pair with the gateway&lt;/a> explained where the trace gets lost between the two pieces.&lt;/p>
&lt;p>What is left uncovered is the work of the next three years: migrating, sizing ClickHouse, retaining and deleting, taking backups, alerting, getting the data out. This series is about that, and it starts with the question that conditions everything else, which is what gets stored.&lt;/p>
&lt;h2 id="the-analogy-the-archive-that-changed-system">The analogy: the archive that changed system&lt;/h2>
&lt;p>An archive with a folder per case file and documents inside each folder is easy to understand. You open the folder, you look at what is there. The problem shows up when you have to find every document of one kind over the last two years: you have to open every folder.&lt;/p>
&lt;p>Large archives solve that the other way around. There is no folder: each document carries, copied into its header, the case reference, the client name and the date. Searching by client is immediate. Reconstructing a case file is grouping by reference. In exchange, each document weighs a little more, and what gets written into that header matters a great deal, because everything you can search for comes from there.&lt;/p>
&lt;p>Langfuse v4 made exactly that change. What used to be a folder with attributes of its own now travels repeated in the header of every document. The two operational consequences of this article come from there: which fields are worth filling in and which fields are not worth filling with content.&lt;/p>
&lt;h2 id="the-data-model-as-it-is-in-the-code">The data model, as it is in the code&lt;/h2>
&lt;h3 id="the-tables">The tables&lt;/h3>
&lt;p>Three new pieces in ClickHouse:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>&lt;code>events_full&lt;/code>&lt;/strong> (migration 0039): the table of record, with the full input and output, compressed with ZSTD level 3 and indexed with full-text indexes over the lowercase versions. &lt;code>ReplacingMergeTree&lt;/code> engine ordered by event timestamp and deletion flag, partitioned by month of the start time.&lt;/li>
&lt;li>&lt;strong>&lt;code>events_core&lt;/code>&lt;/strong> (migration 0040): the listings table, lighter.&lt;/li>
&lt;li>&lt;strong>&lt;code>events_core_mv&lt;/code>&lt;/strong> (migration 0041): the materialised view that fills the previous one, and which &lt;strong>truncates to two hundred characters&lt;/strong> the input, the output and each metadata value.&lt;/li>
&lt;/ul>
&lt;p>That truncation to two hundred characters is the first thing to know. What you see in a listing is not what is stored; it is a clipping. And when reading a full trace a second cap applies on top, &lt;code>LANGFUSE_SERVER_SIDE_IO_CHAR_LIMIT&lt;/code>, with a default value of a thousand (&lt;code>packages/shared/src/env.ts:513&lt;/code>).&lt;/p>
&lt;p>In the &lt;code>events_full&lt;/code> table there is a configuration line that says a lot: &lt;code>index_granularity_bytes = '64Mi'&lt;/code>, with the comment that it avoids very small granules because of large rows. It is the schema itself acknowledging that the rows of this system are bulky.&lt;/p>
&lt;h3 id="the-ten-observation-types">The ten observation types&lt;/h3>
&lt;p>&lt;code>SPAN&lt;/code>, &lt;code>EVENT&lt;/code>, &lt;code>GENERATION&lt;/code>, &lt;code>AGENT&lt;/code>, &lt;code>TOOL&lt;/code>, &lt;code>CHAIN&lt;/code>, &lt;code>RETRIEVER&lt;/code>, &lt;code>EVALUATOR&lt;/code>, &lt;code>EMBEDDING&lt;/code> and &lt;code>GUARDRAIL&lt;/code> (&lt;code>domain/observations.ts:5&lt;/code>), with their corresponding ingestion events (&lt;code>server/ingestion/types.ts:279&lt;/code>).&lt;/p>
&lt;p>For a platform with agents, the difference between marking a tool call as a generic &lt;code>SPAN&lt;/code> or as a &lt;code>TOOL&lt;/code> is the difference between being able to count tool calls per session and having to reconstruct it by hand. It is worth reviewing the instrumentation with this list in front of you.&lt;/p>
&lt;p>The level is still &lt;code>DEBUG&lt;/code>, &lt;code>DEFAULT&lt;/code>, &lt;code>WARNING&lt;/code> and &lt;code>ERROR&lt;/code> (&lt;code>domain/observations.ts:31&lt;/code>), with a free-form status message.&lt;/p>
&lt;h3 id="the-limits-with-their-source">The limits, with their source&lt;/h3>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Field&lt;/th>
&lt;th>Limit&lt;/th>
&lt;th>File&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Trace, span and observation identifiers&lt;/td>
&lt;td>800 characters, no carriage return&lt;/td>
&lt;td>&lt;code>ingestion/types.ts:10&lt;/code>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Trace name&lt;/td>
&lt;td>1,000 characters&lt;/td>
&lt;td>&lt;code>ingestion/types.ts:428&lt;/code>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Environment&lt;/td>
&lt;td>40 characters, pattern of lowercase, digits, hyphen and underscore&lt;/td>
&lt;td>&lt;code>ingestion/types.ts:226&lt;/code>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Text-type score&lt;/td>
&lt;td>500 characters&lt;/td>
&lt;td>&lt;code>domain/scores.ts:44&lt;/code>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Score config name&lt;/td>
&lt;td>35 characters&lt;/td>
&lt;td>&lt;code>domain/score-configs.ts:11&lt;/code>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Classic ingestion body&lt;/td>
&lt;td>4.5 MB per request&lt;/td>
&lt;td>&lt;code>api/public/ingestion.ts:40&lt;/code>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>OTLP body&lt;/td>
&lt;td>512 MiB, adjustable&lt;/td>
&lt;td>&lt;code>web/src/env.mjs:583&lt;/code>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>OTLP span considered large&lt;/td>
&lt;td>9.5 MB, only logged&lt;/td>
&lt;td>&lt;code>packages/shared/src/env.ts:193&lt;/code>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Name, status message, version, release, tags&lt;/td>
&lt;td>&lt;strong>no declared limit&lt;/strong>&lt;/td>
&lt;td>&lt;code>ingestion/types.ts:424&lt;/code>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Metadata&lt;/td>
&lt;td>&lt;strong>no declared limit&lt;/strong>&lt;/td>
&lt;td>&lt;code>ingestion/types.ts:425&lt;/code>&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>The last two are the dangerous ones, because they have no schema cap and the real ceiling ends up being the one on the request body. A three-megabyte metadata value passes validation and reaches ClickHouse.&lt;/p>
&lt;p>A note on the &lt;code>langfuse&lt;/code> prefix in the environment field: it is reserved for the product&amp;rsquo;s own internal traces and is stripped idempotently from public inputs (&lt;code>ingestion/types.ts:228&lt;/code>), including the case of someone trying to sneak it in duplicated. The default value is &lt;code>default&lt;/code>.&lt;/p>
&lt;p>On rate limits: in the cloud there are per-plan quotas, but &lt;strong>in self-hosted they are not applied&lt;/strong>. The service returns directly that there is no limiting (&lt;code>features/public-api/server/RateLimitService.ts&lt;/code>). It is good news for bulk exports and bad news for anyone relying on that mechanism as protection.&lt;/p>
&lt;h2 id="the-two-instrumentation-routes">The two instrumentation routes&lt;/h2>
&lt;h3 id="what-gets-lost-over-otlp-and-it-is-one-specific-thing">What gets lost over OTLP, and it is one specific thing&lt;/h3>
&lt;p>The check is simple and the result is blunt: &lt;strong>there is not a single occurrence of the word &lt;code>score&lt;/code> in the OTLP ingestion processor&lt;/strong>, a file of 3,864 lines. Scores only come in through three places: the &lt;code>score-create&lt;/code> event of the classic ingestion route, the &lt;code>POST /api/public/scores&lt;/code> endpoint, or the interface.&lt;/p>
&lt;p>This has a design consequence worth being clear about before building evaluators: the trace can travel through the OpenTelemetry collector with everything good that brings, on-disk queue, retries and sampling, but the score an evaluator puts on it has to go down a different channel and with the project keys.&lt;/p>
&lt;p>Everything else does travel over OTLP. Whatever is not in the mapping lists is kept as metadata converted to a string, visible but outside the indexed fields.&lt;/p>
&lt;h3 id="the-precedence-chain-which-is-the-map-you-need-to-have">The precedence chain, which is the map you need to have&lt;/h3>
&lt;p>Verified in &lt;code>packages/shared/src/server/otel/OtelIngestionProcessor.ts&lt;/code>:&lt;/p>
&lt;p>&lt;strong>User identifier&lt;/strong> (line 2545), in this order: &lt;code>langfuse.user.id&lt;/code>, &lt;code>user.id&lt;/code>, the observation metadata, the trace metadata, and lastly the Vercel SDK metadata.&lt;/p>
&lt;p>&lt;strong>Session identifier&lt;/strong> (line 2565): &lt;code>langfuse.session.id&lt;/code>, &lt;code>session.id&lt;/code>, &lt;strong>&lt;code>gen_ai.conversation.id&lt;/code>&lt;/strong>, observation metadata, trace metadata, Vercel metadata.&lt;/p>
&lt;p>&lt;strong>Environment&lt;/strong> (line 2383): &lt;code>langfuse.environment&lt;/code>, &lt;code>deployment.environment.name&lt;/code>, &lt;code>deployment.environment&lt;/code>; for each key the span attributes are checked first and the resource attributes after.&lt;/p>
&lt;p>&lt;strong>Model&lt;/strong> (line 2753): &lt;code>langfuse.observation.model.name&lt;/code>, &lt;code>gen_ai.response.model&lt;/code>, &lt;code>ai.model.id&lt;/code>, &lt;code>gen_ai.request.model&lt;/code>, &lt;code>llm.response.model&lt;/code>, &lt;code>llm.model_name&lt;/code>, &lt;code>model&lt;/code>.&lt;/p>
&lt;p>&lt;strong>Level&lt;/strong> (line 1250): the native attribute, with accepted aliases where &lt;code>SUCCESS&lt;/code> and &lt;code>OK&lt;/code> become &lt;code>DEFAULT&lt;/code>, &lt;code>WARN&lt;/code> becomes &lt;code>WARNING&lt;/code> and &lt;code>FATAL&lt;/code> or &lt;code>CRITICAL&lt;/code> become &lt;code>ERROR&lt;/code>; if there is no attribute and the span status is an error, the result is &lt;code>ERROR&lt;/code>.&lt;/p>
&lt;p>&lt;strong>Tags&lt;/strong> (line 3227): &lt;code>langfuse.trace.tags&lt;/code>, &lt;code>langfuse.tags&lt;/code>, observation and trace metadata, Vercel metadata, and &lt;code>tag.tags&lt;/code>.&lt;/p>
&lt;p>&lt;strong>Input and output&lt;/strong> (line 1790): a chain of some twenty frameworks in strict order, starting from Langfuse&amp;rsquo;s own attributes and going through Genkit, the Vercel SDK, the standard inference operation details event of the conventions from 1.37 onwards, the per-role message events, the prompt and completion attributes, Logfire, MLflow, TraceLoop, OpenInference and the &lt;code>gen_ai.input.messages&lt;/code> and &lt;code>gen_ai.output.messages&lt;/code> attributes.&lt;/p>
&lt;p>That this chain has twenty entries says something about the real state of the conventions: it was &lt;a href="https://blog.lo0.es/en/posts/litellm-mcp-gateway-second-front-door/">already noted&lt;/a> that the generative AI conventions were extracted into their own repository without any published version. While that remains the case, the backend has to guess, and it guesses with a precedence list.&lt;/p>
&lt;p>The practical part: &lt;strong>if Langfuse&amp;rsquo;s own attributes are emitted, they always win&lt;/strong>. If you want backend-neutral instrumentation, &lt;code>gen_ai.conversation.id&lt;/code> and &lt;code>deployment.environment.name&lt;/code> are the two standard attributes that Langfuse does understand.&lt;/p>
&lt;h3 id="metadata-and-how-it-ends-up-in-the-table">Metadata, and how it ends up in the table&lt;/h3>
&lt;p>A metadata value is flattened into two parallel arrays, one of names and one of values, with dot-separated paths and everything converted to a string (&lt;code>otel/utils.ts:239&lt;/code>, applied in &lt;code>worker/src/services/IngestionService/index.ts:363&lt;/code>). A nested object with twenty keys is twenty entries in each array.&lt;/p>
&lt;p>There is a cap of 10,001 positions when reconstructing arrays from OTel attributes (&lt;code>OtelIngestionProcessor.ts:271&lt;/code>), which is high but it exists.&lt;/p>
&lt;h2 id="sessions-users-and-cardinality">Sessions, users and cardinality&lt;/h2>
&lt;p>A session is a text column with a Bloom filter index at 1 %. The same goes for the user. The Postgres table that accompanies sessions stores only whether it is bookmarked, whether it is public and its environment; there is no counter and no cap. &lt;strong>There is no limit on traces per session in the code&lt;/strong>, and the documentation recommends session identifiers below 200 ASCII characters.&lt;/p>
&lt;p>The cardinality difference matters and it is not intuitive: user and session are plain text, so high cardinality does not break the schema, it only inflates the Bloom filters. &lt;strong>The environment, by contrast, is declared low cardinality.&lt;/strong> Generating a dynamic environment value per tenant degrades ClickHouse measurably.&lt;/p>
&lt;p>Aggregated cost comes out of a cost details map with materialised input, output and total columns. The classic sessions endpoint is deprecated; the replacement is the version 2 observations one with a filter.&lt;/p>
&lt;h2 id="scores-five-types-and-three-sources">Scores: five types and three sources&lt;/h2>
&lt;p>The types are &lt;code>NUMERIC&lt;/code>, &lt;code>CATEGORICAL&lt;/code>, &lt;code>BOOLEAN&lt;/code>, &lt;code>CORRECTION&lt;/code> and &lt;code>TEXT&lt;/code> (&lt;code>domain/scores.ts:45&lt;/code>). The boolean requires a value of zero or one. The correction does not accept an associated config. The text one is capped at 500 characters.&lt;/p>
&lt;p>The sources are &lt;code>API&lt;/code>, &lt;code>EVAL&lt;/code> and &lt;code>ANNOTATION&lt;/code>. The &lt;code>EVAL&lt;/code> source is &lt;strong>reserved for the internal evaluators&lt;/strong>: the public API only accepts the other two. Annotation scores require an associated config except for the correction one.&lt;/p>
&lt;p>On what costs money, a clarification that corrects a widespread belief: in 4.35.0 the entitlement for the number of model-based evaluators is unlimited &lt;strong>on every plan&lt;/strong>, including the open edition (&lt;code>features/entitlements/constants/entitlements.ts:60&lt;/code>). What is limited by plan is the number of annotation queues and the days of data access. And there is a version nuance: trace-level evaluators are discontinued in version 4.&lt;/p>
&lt;h2 id="masking-and-what-it-does-not-cover">Masking, and what it does not cover&lt;/h2>
&lt;p>This is the part to read twice if the deployment has to comply with anything.&lt;/p>
&lt;p>The classic Python SDK function has the signature &lt;code>masking_function(*, data, **kwargs)&lt;/code> and covers &lt;strong>only&lt;/strong> the data that passes through the SDK calls: observation creation, update, and setting the trace input and output. &lt;strong>It does not cover the raw OTel attributes&lt;/strong> produced by a third-party automatic instrumentation.&lt;/p>
&lt;p>The replacement is a function that acts on the attributes and the resource attributes in the export batch. That one does reach automatic instrumentation, with a limitation declared in the documentation: it cannot change the span name, the identifiers, the parent relationship, the resource attributes, &lt;strong>the events&lt;/strong> or the links.&lt;/p>
&lt;p>And there is the gap that matters: the generative AI conventions from 1.37 onwards put messages and responses into &lt;strong>span events&lt;/strong>. That is, the route by which prompts arrive in a modern instrumentation is precisely the one the new masking does not touch.&lt;/p>
&lt;p>For a deployment with personal data, the conclusion is that client-side masking is not enough and a redaction processor has to go into the collector. It is the same reasoning that led &lt;a href="https://blog.lo0.es/en/posts/gateway-jwt-audience-trace-identity-seams/">in the previous article&lt;/a> to recommending the collector in the middle, and it adds to the already documented problem of MCP tool arguments being written in clear, bypassing the message redaction switch.&lt;/p>
&lt;p>On the server side there is no safety net: &lt;strong>Langfuse has no server-side personal data masking&lt;/strong>. The encryption key that gets configured protects secrets, that is, model provider credentials, integration headers and SSO configurations. &lt;strong>Trace inputs and outputs are not encrypted at application level.&lt;/strong>&lt;/p>
&lt;h2 id="volume-control">Volume control&lt;/h2>
&lt;p>Three levers, in order of how high up they act:&lt;/p>
&lt;p>&lt;strong>In the SDK&lt;/strong>: &lt;code>sample_rate&lt;/code>, or the &lt;code>LANGFUSE_SAMPLE_RATE&lt;/code> variable, with head sampling at trace level. If the trace is not sampled, neither its observations nor its scores travel. &lt;code>LANGFUSE_TRACING_ENABLED&lt;/code> set to false switches the whole thing off.&lt;/p>
&lt;p>&lt;strong>In the collector&lt;/strong>: probabilistic sampling and tail sampling. It is the only layer where you can decide based on the outcome, that is, keep every error and a fraction of the rest.&lt;/p>
&lt;p>&lt;strong>In the server&lt;/strong>: a deterministic per-project sampling, with a SHA-256 hash of the trace identifier (&lt;code>server/ingestion/sampling.ts&lt;/code>). Deterministic per trace, so it does not cut traces in half.&lt;/p>
&lt;h2 id="what-not-to-put-into-a-trace">What not to put into a trace&lt;/h2>
&lt;p>With the schema in front of you, the list is concrete.&lt;/p>
&lt;p>&lt;strong>Do not filter by tags or by status message.&lt;/strong> In the listings table the indexed fields are the span identifier, the trace identifier, the user identifier, the session identifier, the creation and update dates, the model name, the experiment identifier and the metadata names. The name, the tags, the version, the release and the status message are not. Filtering by them scans.&lt;/p>
&lt;p>&lt;strong>Do not use metadata as a store.&lt;/strong> It gets flattened into string arrays, truncated to two hundred characters in the listings table but stored whole in the full table, and on top of that it is indexed with a full-text index segmented on non-alphabetic characters. Bulky metadata multiplies the inverted index, not just the data.&lt;/p>
&lt;p>&lt;strong>Do not store full prompts without thinking about it.&lt;/strong> The input and the output carry ZSTD level 3 compression and two full-text indexes over their lowercase versions. Storing the entire context of a 142,000-token agentic call on every turn inflates the inverted index disproportionately. Client-side truncation is the cheap lever.&lt;/p>
&lt;p>&lt;strong>Do not use the environment to separate tenants.&lt;/strong> It is low cardinality, it has a 40-character cap and it isolates nothing: same project, same keys, same limits, same retention. It is there to segment dashboards. Real isolation means separate projects, as was already argued &lt;a href="https://blog.lo0.es/en/posts/gateway-jwt-audience-trace-identity-seams/">in the article on the seams&lt;/a>.&lt;/p>
&lt;h2 id="version-3-and-the-calendar-date">Version 3 and the calendar date&lt;/h2>
&lt;p>The constant is in the code: &lt;code>V3_SUNSET_DATE = &amp;quot;2026-11-16&amp;quot;&lt;/code> (&lt;code>features/public-api/server/deprecations.ts:11&lt;/code>).&lt;/p>
&lt;p>It is worth being precise about what it means, because the public documentation and the code do not say exactly the same thing. The code comment is literal: the classic ingestion route &lt;strong>is never switched off&lt;/strong> and continues to accept score events; trace and observation events fail only in version 4&amp;rsquo;s exclusive write mode, not in the mixed or classic modes. And when they fail, they fail &lt;strong>per event&lt;/strong>, not per batch.&lt;/p>
&lt;p>The &lt;code>x-langfuse-ingestion-version: 4&lt;/code> header activates direct writing to the events table, which is the real-time route. Without it the deferred path is taken. Recent SDKs send it by themselves. Any value above four is rejected.&lt;/p>
&lt;p>As for names, trace-level input and output attributes are deprecated in favour of the observation ones on the root span. The classic read endpoints carry a deprecation marker in the response body, and their replacements are version 2 of observations, version 3 of scores and version 2 of metrics. And dataset runs are renamed experiments.&lt;/p>
&lt;p>There is a curious piece that deserves a mention because it shows up in the Redis dashboards and is misleading: a queue called version 4 classic API usage, which &lt;strong>ingests nothing&lt;/strong>. It is a periodic task every fifteen minutes that scans the ClickHouse query log to detect which projects are still using the old API, and materialises the result into Redis for the interface&amp;rsquo;s migration notice.&lt;/p>
&lt;h2 id="how-this-looks-in-code">How this looks in code&lt;/h2>
&lt;p>Two pieces, the client one and the collector one.&lt;/p>
&lt;p>On the application side, what changes compared with the usual instrumentation is the observation type and where the identity comes from:&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">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="n">lf&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>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># identity propagates by context, not observation by observation&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">session_id&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">id_conversacion&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="c1"># the same across every turn&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">sujeto_del_token&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="c1"># from the claim, not from the body&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;produccion&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">tags&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;soporte&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s2">&amp;#34;n2&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="c1"># the type matters: the interface groups by it&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">lf&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">start_as_current_observation&lt;/span>&lt;span class="p">(&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &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 class="n">name&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;resolver-incidencia&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">)&lt;/span> &lt;span class="k">as&lt;/span> &lt;span class="n">raiz&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">lf&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">start_as_current_observation&lt;/span>&lt;span class="p">(&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">as_type&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;tool&amp;#34;&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;buscar_en_inventario&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">)&lt;/span> &lt;span class="k">as&lt;/span> &lt;span class="n">herramienta&lt;/span>&lt;span class="p">:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">resultado&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">mcp&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">call&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;buscar&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">args&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="c1"># truncate here, not on the server&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">herramienta&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">update&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">output&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="nb">str&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">resultado&lt;/span>&lt;span class="p">)[:&lt;/span>&lt;span class="mi">4000&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"># the score goes down its own channel, never over OTLP&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">lf&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">score_current_trace&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;resuelta&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &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 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;/code>&lt;/pre>&lt;/div>&lt;p>The &lt;code>as_type&lt;/code> parameter accepts nine literals: &lt;code>span&lt;/code>, &lt;code>generation&lt;/code>, &lt;code>embedding&lt;/code>, &lt;code>agent&lt;/code>, &lt;code>tool&lt;/code>, &lt;code>chain&lt;/code>, &lt;code>retriever&lt;/code>, &lt;code>evaluator&lt;/code> and &lt;code>guardrail&lt;/code>. The tenth type in the model, the event, has its own constructor because it does not open a scope.&lt;/p>
&lt;p>On the collector side, the redaction processor that compensates for what the SDK masking does not reach:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-yaml" data-lang="yaml">&lt;span class="line">&lt;span class="cl">&lt;span class="nt">processors&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">redaction&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">allow_all_keys&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kc">true&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">blocked_values&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="s1">&amp;#39;\b\d{8}[A-HJ-NP-TV-Z]\b&amp;#39;&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c"># national ID number&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="s1">&amp;#39;\b[\w.+-]+@[\w-]+\.[\w.]+\b&amp;#39;&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c"># email&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">summary&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">silent&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">tail_sampling&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">policies&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">errores&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">type&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">status_code&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">status_code&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>{&lt;span class="nt">status_codes&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="l">ERROR]}&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">resto&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">type&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">probabilistic&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">probabilistic&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>{&lt;span class="nt">sampling_percentage&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">5&lt;/span>}&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">exporters&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">otlphttp/langfuse&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">endpoint&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">http://langfuse-web.observabilidad.svc:3000/api/public/otel&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">headers&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">Authorization&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;Basic ${LANGFUSE_BASIC}&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">x-langfuse-ingestion-version&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;4&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">sending_queue&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">enabled&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kc">true&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">storage&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">file_storage&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">retry_on_failure&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">enabled&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kc">true&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>The endpoint carries the base path without the signal suffix: the exporter adds its own. And the on-disk queue is the main reason for putting the collector in the middle, because the SDK&amp;rsquo;s batch processor drops when it fills up.&lt;/p>
&lt;h2 id="instrumentation-checklist">Instrumentation checklist&lt;/h2>
&lt;ol>
&lt;li>Review the type of every observation against the list of ten. Tool calls as &lt;code>TOOL&lt;/code>, agents as &lt;code>AGENT&lt;/code>, guardrails as &lt;code>GUARDRAIL&lt;/code>.&lt;/li>
&lt;li>Decide where the user identifier comes from and set it in a single place, high up the precedence chain.&lt;/li>
&lt;li>Emit &lt;code>gen_ai.conversation.id&lt;/code> if you want neutral instrumentation, or Langfuse&amp;rsquo;s own attribute if coupling does not matter.&lt;/li>
&lt;li>Set the environment with &lt;code>deployment.environment.name&lt;/code>, with few distinct values and never one per tenant.&lt;/li>
&lt;li>Get scores out through their own channel, with the project keys, and do not expect them to travel with the trace.&lt;/li>
&lt;li>Truncate input and output on the client before sending them, especially with long agentic contexts.&lt;/li>
&lt;li>Put a redaction processor in the collector, because the SDK masking leaves out span events.&lt;/li>
&lt;li>Do not filter by tags in dashboards that get queried often.&lt;/li>
&lt;li>Send the ingestion version header, or use recent SDKs that send it by themselves.&lt;/li>
&lt;li>Review the size of the metadata: there is no schema cap, the cap is the one on the request body.&lt;/li>
&lt;/ol>
&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;p>&lt;strong>What you see in a listing is truncated to two hundred characters&lt;/strong> by the materialised view, and the full read has another cap of a thousand. The data is whole in the full events table.&lt;/p>
&lt;p>&lt;strong>Metadata has no size limit in the schema.&lt;/strong> A blob of megabytes passes validation.&lt;/p>
&lt;p>&lt;strong>An OTLP span over 9.5 MB is not rejected, it is only logged.&lt;/strong> There is no signal back to the client.&lt;/p>
&lt;p>&lt;strong>The environment is declared low cardinality.&lt;/strong> One value per tenant degrades ClickHouse.&lt;/p>
&lt;p>&lt;strong>Scores do not travel over OTLP.&lt;/strong> It is by absence in the code, not by configuration.&lt;/p>
&lt;p>&lt;strong>The classic masking does not cover automatic instrumentation&lt;/strong>, and the new one does not cover span events, which is where the recent conventions put the messages.&lt;/p>
&lt;p>&lt;strong>Langfuse does not encrypt trace inputs or outputs.&lt;/strong> The encryption key protects integration credentials.&lt;/p>
&lt;p>&lt;strong>In self-hosted there are no rate limits on the API.&lt;/strong> It is no use as protection.&lt;/p>
&lt;p>&lt;strong>The classic ingestion route is not switched off on 16 November&lt;/strong>, it changes behaviour depending on the write mode, and it fails per event instead of per batch.&lt;/p>
&lt;p>&lt;strong>Model-based evaluators are not limited by plan&lt;/strong> in this version, contrary to what is usually assumed. What is limited are the annotation queues and the days of data access.&lt;/p>
&lt;h2 id="the-series-the-ten-articles">The series: the ten articles&lt;/h2>
&lt;p>This is the first. The order is designed so that each one can be read on its own and so that together they cover the whole of day 2.&lt;/p>
&lt;ol>
&lt;li>What goes into a trace (this article): 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>&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-inside-sorting-centre-bottleneck/">Langfuse from the inside&lt;/a>: the six-service architecture and the ten performance levers of version 3.&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 the two pieces.&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 user is not the Keycloak one, and when to put a collector in the middle.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/llm-tracing-opentelemetry-genai/">LLM tracing with OpenTelemetry GenAI&lt;/a>: the pipeline and the semantic conventions.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/prompt-versioning-contract-five-word-change/">Prompt versioning with Langfuse and MLflow&lt;/a>: the tool&amp;rsquo;s other layer.&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 scores fit.&lt;/li>
&lt;/ul>
&lt;h2 id="sources">Sources&lt;/h2>
&lt;ul>
&lt;li>Código de Langfuse 4.35.0, commit &lt;code>39e3cd7d&lt;/code> del 11 de septiembre de 2026.&lt;/li>
&lt;li>&lt;a href="https://langfuse.com/changelog/2026-08-17-langfuse-v4">Anuncio de Langfuse v4&lt;/a>, 17 de agosto de 2026.&lt;/li>
&lt;li>&lt;a href="https://langfuse.com/docs/compatibility">Compatibilidad y migración&lt;/a>, consultado el 12 de septiembre de 2026.&lt;/li>
&lt;li>&lt;a href="https://langfuse.com/docs/observability/features/sessions">Sesiones&lt;/a>, &lt;a href="https://langfuse.com/docs/observability/features/sampling">muestreo&lt;/a> y &lt;a href="https://langfuse.com/docs/observability/features/masking">enmascarado&lt;/a>, consultados el 12 de septiembre de 2026.&lt;/li>
&lt;li>&lt;a href="https://langfuse.com/integrations/native/opentelemetry">Integración nativa con OpenTelemetry&lt;/a>, consultado el 12 de septiembre de 2026.&lt;/li>
&lt;/ul></description></item><item><title>Langfuse from the inside: the sorting centre that must not become the bottleneck it came to observe</title><link>https://blog.lo0.es/en/posts/langfuse-inside-sorting-centre-bottleneck/</link><pubDate>Sat, 06 Jun 2026 06:00:00 +0200</pubDate><guid>https://blog.lo0.es/en/posts/langfuse-inside-sorting-centre-bottleneck/</guid><description>&lt;blockquote>
&lt;p>This post closes a trilogy of the &lt;strong>Observe&lt;/strong> layer: in &lt;a href="https://blog.lo0.es/en/posts/llm-tracing-opentelemetry-genai/">LLM tracing with OpenTelemetry GenAI&lt;/a> the &lt;code>SDK → Collector → backend&lt;/code> pipeline was set up and Langfuse was treated as a black box that receives spans; in &lt;a href="https://blog.lo0.es/en/posts/prompt-versioning-contract-five-word-change/">Prompt versioning with Langfuse and MLflow&lt;/a> its prompt management layer was used. Here we open the box: what is inside Langfuse, why v3 stopped being a monolith on Postgres, and how it is operated so it holds the traffic of an inference cluster without becoming the problem.&lt;/p>
&lt;/blockquote>
&lt;h2 id="tldr">TL;DR&lt;/h2>
&lt;p>Langfuse v3 (stable since December 2024) &lt;strong>is not an application, it is six services&lt;/strong>: two containers of its own (Web and Worker) and four stateful dependencies (Postgres, ClickHouse, Redis/Valkey and an S3-compatible blob store). The key architectural change compared with v2, which was a Next.js monolith on Postgres, is the &lt;strong>asynchronous ingestion pipeline&lt;/strong>: traces are received in batches, written &lt;em>immediately&lt;/em> to S3, only a &lt;em>reference&lt;/em> is queued in Redis, and a Worker ingests them into ClickHouse in the background. This decouples reception speed (limited only by Redis write latency, ~1-5 ms) from the cost of persisting and merging into the analytical database. The result: the Web container sustains hundreds of events per second without a spike blocking the client that serves the inference. But that design only performs with the right settings. This post covers the architecture, its interaction with the rest of the on-premise stack, and &lt;strong>ten backend knobs&lt;/strong>, from batching to ClickHouse to queue sharding, from the &lt;code>FINAL&lt;/code> modifier to system log table hygiene, that decide the real throughput and the storage cost. And it marks where the async design hides data loss windows worth knowing about before promising &amp;ldquo;full traceability&amp;rdquo;.&lt;/p>
&lt;h2 id="you-are-here-observe-and-the-layer-that-holds-up-the-rest">You are here: OBSERVE (and the layer that holds up the rest)&lt;/h2>
&lt;div class="diagram" style="max-width:780px;margin:1rem auto;">
&lt;svg viewBox="0 0 780 90" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="you are here: Observe">
&lt;defs>&lt;marker id="lfm" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="6" markerHeight="6" orient="auto">&lt;path d="M0,0 L10,5 L0,10 z" fill="#666"/>&lt;/marker>&lt;/defs>
&lt;text x="390" y="20" text-anchor="middle" font-family="sans-serif" font-size="12" font-weight="600" fill="currentColor">You are here: OBSERVE · the storage substrate that makes tracing operable&lt;/text>
&lt;rect x="30" y="35" width="110" height="35" rx="6" fill="#f4f4f4" stroke="#444" stroke-width="1.4"/>&lt;text x="85" y="58" text-anchor="middle" font-family="sans-serif" font-size="12" font-weight="600" fill="#222">1 · Data&lt;/text>
&lt;rect x="155" y="35" width="110" height="35" rx="6" fill="#f4f4f4" stroke="#444" stroke-width="1.4"/>&lt;text x="210" y="58" text-anchor="middle" font-family="sans-serif" font-size="12" font-weight="600" fill="#222">2 · Tune&lt;/text>
&lt;rect x="280" y="35" width="110" height="35" rx="6" fill="#f4f4f4" stroke="#444" stroke-width="1.4"/>&lt;text x="335" y="58" text-anchor="middle" font-family="sans-serif" font-size="12" font-weight="600" fill="#222">3 · Eval&lt;/text>
&lt;rect x="405" y="35" width="110" height="35" rx="6" fill="#f4f4f4" stroke="#444" stroke-width="1.4"/>&lt;text x="460" y="58" text-anchor="middle" font-family="sans-serif" font-size="12" font-weight="600" fill="#222">4 · Deploy&lt;/text>
&lt;rect x="530" y="35" width="110" height="35" rx="6" fill="#c9a8e9" stroke="#444" stroke-width="3"/>&lt;text x="585" y="58" text-anchor="middle" font-family="sans-serif" font-size="12" font-weight="600" fill="#222">5 · Observe&lt;/text>
&lt;rect x="655" y="35" width="110" height="35" rx="6" fill="#f4f4f4" stroke="#444" stroke-width="1.4"/>&lt;text x="710" y="58" text-anchor="middle" font-family="sans-serif" font-size="12" font-weight="600" fill="#222">6 · Retrain&lt;/text>
&lt;path d="M140,52 L155,52" fill="none" stroke="#666" stroke-width="1.4" marker-end="url(#lfm)"/>
&lt;path d="M265,52 L280,52" fill="none" stroke="#666" stroke-width="1.4" marker-end="url(#lfm)"/>
&lt;path d="M390,52 L405,52" fill="none" stroke="#666" stroke-width="1.4" marker-end="url(#lfm)"/>
&lt;path d="M515,52 L530,52" fill="none" stroke="#666" stroke-width="1.4" marker-end="url(#lfm)"/>
&lt;path d="M640,52 L655,52" fill="none" stroke="#666" stroke-width="1.4" marker-end="url(#lfm)"/>
&lt;/svg>
&lt;/div>
&lt;h2 id="the-analogy-the-postal-sorting-centre">The analogy: the postal sorting centre&lt;/h2>
&lt;p>Picture the central mail sorting office of a large city at rush hour. Lorries loaded with &lt;strong>sacks&lt;/strong> (batches of letters) arrive at a rate that does not stop. If the counter clerk had to &lt;strong>open every sack, read every letter, decide its destination and file it&lt;/strong> before accepting the next lorry, the queue of lorries would go round the block in ten minutes. No serious sorting centre works like that.&lt;/p>
&lt;p>What they do is &lt;strong>decouple reception from processing&lt;/strong>:&lt;/p>
&lt;ol>
&lt;li>The &lt;strong>reception counter&lt;/strong> accepts the sack, stamps a receipt on it, leaves it in a &lt;strong>pigeonhole&lt;/strong> in the warehouse and drops a &lt;strong>ticket&lt;/strong> on a conveyor belt. Time per sack: seconds. The counter never blocks.&lt;/li>
&lt;li>Further back, in the &lt;strong>sorting room&lt;/strong>, a team of operators picks tickets off the belt, retrieves the sack from its pigeonhole, opens it, sorts the letters and files them in the &lt;strong>permanent archive&lt;/strong>, ordered, indexed, searchable.&lt;/li>
&lt;/ol>
&lt;p>Langfuse v3 is exactly this sorting centre:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Postal centre&lt;/th>
&lt;th>Langfuse v3&lt;/th>
&lt;th>Function&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Reception counter&lt;/td>
&lt;td>&lt;strong>Web&lt;/strong> container (ingestion endpoint)&lt;/td>
&lt;td>Accepts event batches, gives an immediate receipt (HTTP 207)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Pigeonhole warehouse&lt;/td>
&lt;td>&lt;strong>S3 / Blob store&lt;/strong> (MinIO on-prem)&lt;/td>
&lt;td>Stores the raw sack (the full event)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Ticket on the belt&lt;/td>
&lt;td>&lt;strong>Redis / Valkey&lt;/strong> (BullMQ queue)&lt;/td>
&lt;td>Only the &lt;em>reference&lt;/em> to the object in S3, not the content&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Sorting room&lt;/td>
&lt;td>&lt;strong>Worker&lt;/strong> container&lt;/td>
&lt;td>Takes tickets, reads S3, transforms and files&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Indexed permanent archive&lt;/td>
&lt;td>&lt;strong>ClickHouse&lt;/strong> (OLAP)&lt;/td>
&lt;td>Traces, observations and scores, queryable by project+time&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Administrative register&lt;/td>
&lt;td>&lt;strong>Postgres&lt;/strong> (OLTP)&lt;/td>
&lt;td>Users, projects, API keys, prompts, datasets, config&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>The thesis of the whole post follows from this analogy: &lt;strong>the value of Langfuse lies in the counter never blocking the client that serves the inference&lt;/strong>. An observability tool that adds latency or outages to the token-serving path is worse than having no observability, because it degrades precisely the system it set out to look after. The whole of the v3 design, and every knob in this post, exists to keep that promise under load.&lt;/p>
&lt;h2 id="the-mechanism-itself-six-services-two-planes">The mechanism itself: six services, two planes&lt;/h2>
&lt;p>Langfuse v3 separates two planes that in v2 were fused together:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Ingestion and query plane&lt;/strong> (the two containers of its own, &lt;em>stateless&lt;/em>, horizontally scalable): Web and Worker.&lt;/li>
&lt;li>&lt;strong>State plane&lt;/strong> (four dependencies, each with its own load profile): Postgres (transactional OLTP), ClickHouse (analytical OLAP), Redis/Valkey (queue + cache), Blob store (raw objects).&lt;/li>
&lt;/ul>
&lt;div class="diagram" style="max-width:820px;margin:1.2rem auto;">
&lt;svg viewBox="0 0 820 470" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Six-service architecture of Langfuse v3">
&lt;defs>&lt;marker id="lfa" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto">&lt;path d="M0,0 L10,5 L0,10 z" fill="#666"/>&lt;/marker>&lt;/defs>
&lt;text x="410" y="24" text-anchor="middle" font-family="sans-serif" font-size="14" font-weight="700" fill="currentColor">Langfuse v3 · six services, two planes&lt;/text>
&lt;!-- Clients -->
&lt;rect x="30" y="50" width="150" height="60" rx="8" fill="#eef3fb" stroke="#4a6fa5" stroke-width="1.6"/>
&lt;text x="105" y="74" text-anchor="middle" font-family="sans-serif" font-size="12" font-weight="700" fill="#222">SDKs / OTel&lt;/text>
&lt;text x="105" y="92" text-anchor="middle" font-family="sans-serif" font-size="10.5" fill="#444">apps, vLLM, gateway&lt;/text>
&lt;!-- Stateless plane -->
&lt;rect x="240" y="44" width="300" height="150" rx="10" fill="none" stroke="#999" stroke-width="1.2" stroke-dasharray="5 3"/>
&lt;text x="390" y="40" text-anchor="middle" font-family="sans-serif" font-size="11" font-weight="600" fill="currentColor">Stateless plane (horizontal scale)&lt;/text>
&lt;rect x="262" y="62" width="120" height="58" rx="8" fill="#dceede" stroke="#3c8c54" stroke-width="1.8"/>
&lt;text x="322" y="84" text-anchor="middle" font-family="sans-serif" font-size="12" font-weight="700" fill="#222">Web&lt;/text>
&lt;text x="322" y="101" text-anchor="middle" font-family="sans-serif" font-size="9.5" fill="#444">ingestion + UI/API&lt;/text>
&lt;rect x="398" y="62" width="120" height="58" rx="8" fill="#dceede" stroke="#3c8c54" stroke-width="1.8"/>
&lt;text x="458" y="84" text-anchor="middle" font-family="sans-serif" font-size="12" font-weight="700" fill="#222">Worker&lt;/text>
&lt;text x="458" y="101" text-anchor="middle" font-family="sans-serif" font-size="9.5" fill="#444">sorts → CH&lt;/text>
&lt;rect x="262" y="135" width="256" height="46" rx="8" fill="#f7efda" stroke="#c79a32" stroke-width="1.6"/>
&lt;text x="390" y="153" text-anchor="middle" font-family="sans-serif" font-size="11" font-weight="700" fill="#222">Redis / Valkey&lt;/text>
&lt;text x="390" y="170" text-anchor="middle" font-family="sans-serif" font-size="9.5" fill="#444">BullMQ queue (refs) + API key/prompt cache&lt;/text>
&lt;!-- State plane -->
&lt;rect x="240" y="232" width="540" height="200" rx="10" fill="none" stroke="#999" stroke-width="1.2" stroke-dasharray="5 3"/>
&lt;text x="510" y="228" text-anchor="middle" font-family="sans-serif" font-size="11" font-weight="600" fill="currentColor">State plane&lt;/text>
&lt;rect x="262" y="250" width="150" height="74" rx="8" fill="#f3dede" stroke="#b35454" stroke-width="1.6"/>
&lt;text x="337" y="274" text-anchor="middle" font-family="sans-serif" font-size="11.5" font-weight="700" fill="#222">Blob store (S3)&lt;/text>
&lt;text x="337" y="291" text-anchor="middle" font-family="sans-serif" font-size="9.5" fill="#444">MinIO on-prem&lt;/text>
&lt;text x="337" y="306" text-anchor="middle" font-family="sans-serif" font-size="9.5" fill="#444">raw events + media&lt;/text>
&lt;rect x="437" y="250" width="150" height="74" rx="8" fill="#dde6f3" stroke="#4a6fa5" stroke-width="1.6"/>
&lt;text x="512" y="274" text-anchor="middle" font-family="sans-serif" font-size="11.5" font-weight="700" fill="#222">ClickHouse&lt;/text>
&lt;text x="512" y="291" text-anchor="middle" font-family="sans-serif" font-size="9.5" fill="#444">OLAP · traces,&lt;/text>
&lt;text x="512" y="306" text-anchor="middle" font-family="sans-serif" font-size="9.5" fill="#444">observations, scores&lt;/text>
&lt;rect x="612" y="250" width="150" height="74" rx="8" fill="#e6ddf3" stroke="#7a5aa5" stroke-width="1.6"/>
&lt;text x="687" y="274" text-anchor="middle" font-family="sans-serif" font-size="11.5" font-weight="700" fill="#222">Postgres&lt;/text>
&lt;text x="687" y="291" text-anchor="middle" font-family="sans-serif" font-size="9.5" fill="#444">OLTP · orgs, users,&lt;/text>
&lt;text x="687" y="306" text-anchor="middle" font-family="sans-serif" font-size="9.5" fill="#444">API keys, prompts&lt;/text>
&lt;!-- ingestion flow -->
&lt;text x="510" y="356" text-anchor="middle" font-family="sans-serif" font-size="10.5" font-weight="600" fill="#3c8c54">Ingestion route (asynchronous)&lt;/text>
&lt;text x="510" y="376" text-anchor="middle" font-family="sans-serif" font-size="10" fill="#444">① Web writes event → S3 ② Web queues ref → Redis&lt;/text>
&lt;text x="510" y="394" text-anchor="middle" font-family="sans-serif" font-size="10" fill="#444">③ Worker pulls ref from Redis ④ reads S3 → ⑤ inserts into ClickHouse&lt;/text>
&lt;text x="510" y="416" text-anchor="middle" font-family="sans-serif" font-size="10" fill="#444">UI/API: Web reads from ClickHouse (traces) + Postgres (config)&lt;/text>
&lt;!-- connections -->
&lt;path d="M180,80 L262,84" fill="none" stroke="#666" stroke-width="1.6" marker-end="url(#lfa)"/>
&lt;text x="218" y="72" text-anchor="middle" font-family="sans-serif" font-size="9" fill="#666">batch&lt;/text>
&lt;path d="M322,120 L322,135" fill="none" stroke="#666" stroke-width="1.4" marker-end="url(#lfa)"/>
&lt;path d="M390,181 L420,232" fill="none" stroke="#666" stroke-width="1.4" marker-end="url(#lfa)"/>
&lt;path d="M458,135 L458,120" fill="none" stroke="#666" stroke-width="1.4" marker-end="url(#lfa)"/>
&lt;path d="M458,120 L458,135" fill="none" stroke="#666" stroke-width="1.4"/>
&lt;path d="M412,287 L437,287" fill="none" stroke="#666" stroke-width="1.4" marker-end="url(#lfa)"/>
&lt;path d="M448,120 L500,250" fill="none" stroke="#666" stroke-width="1.4" marker-end="url(#lfa)"/>
&lt;path d="M518,120 L660,250" fill="none" stroke="#666" stroke-width="1.2" marker-end="url(#lfa)" stroke-dasharray="3 2"/>
&lt;/svg>
&lt;/div>
&lt;p>What to take away from this diagram:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Web and Worker are interchangeable and stateless.&lt;/strong> They store nothing locally. You can run 1 or 20 replicas of each; the state lives in the four dependencies. This is what allows scaling by load without choreography.&lt;/li>
&lt;li>&lt;strong>Redis never carries the event content, only the reference&lt;/strong> to the object in S3. That is why Redis holds the spike: a Redis write is ~1-5 ms and moves bytes, not kilobytes. The bottleneck of the Web container is, literally, &lt;em>Redis write speed&lt;/em>.&lt;/li>
&lt;li>&lt;strong>Postgres and ClickHouse have opposite profiles.&lt;/strong> Postgres is OLTP: many small transactional reads and writes (is this API key valid? which version does the &lt;code>production&lt;/code> label point to?). ClickHouse is OLAP: few enormous batch writes and analytical queries over billions of rows (give me the p95 TTFT of project X over the last 7 days). Putting traces in Postgres, which is what v2 did, works until it does not: at production volume, Postgres drowns in a workload it was not designed for. That was the reason for the redesign.&lt;/li>
&lt;/ul>
&lt;h2 id="the-ingestion-flow-step-by-step-and-the-maths-of-the-decoupling">The ingestion flow step by step (and the maths of the decoupling)&lt;/h2>
&lt;p>The heart of the design is the ingestion route. Seen in detail, a &lt;code>POST /api/public/ingestion&lt;/code> request with a batch of events does this:&lt;/p>
&lt;div class="diagram" style="max-width:780px;margin:1.2rem auto;">
&lt;svg viewBox="0 0 780 300" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Langfuse asynchronous ingestion sequence">
&lt;defs>&lt;marker id="lfs" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto">&lt;path d="M0,0 L10,5 L0,10 z" fill="#666"/>&lt;/marker>&lt;/defs>
&lt;!-- lifelines -->
&lt;text x="80" y="30" text-anchor="middle" font-family="sans-serif" font-size="11" font-weight="700" fill="currentColor">Client&lt;/text>
&lt;text x="250" y="30" text-anchor="middle" font-family="sans-serif" font-size="11" font-weight="700" fill="#3c8c54">Web&lt;/text>
&lt;text x="420" y="30" text-anchor="middle" font-family="sans-serif" font-size="11" font-weight="700" fill="#b35454">S3&lt;/text>
&lt;text x="560" y="30" text-anchor="middle" font-family="sans-serif" font-size="11" font-weight="700" fill="#c79a32">Redis&lt;/text>
&lt;text x="700" y="30" text-anchor="middle" font-family="sans-serif" font-size="11" font-weight="700" fill="#4a6fa5">CH+Worker&lt;/text>
&lt;line x1="80" y1="40" x2="80" y2="280" stroke="#ccc" stroke-width="1"/>
&lt;line x1="250" y1="40" x2="250" y2="280" stroke="#ccc" stroke-width="1"/>
&lt;line x1="420" y1="40" x2="420" y2="280" stroke="#ccc" stroke-width="1"/>
&lt;line x1="560" y1="40" x2="560" y2="280" stroke="#ccc" stroke-width="1"/>
&lt;line x1="700" y1="40" x2="700" y2="280" stroke="#ccc" stroke-width="1"/>
&lt;path d="M80,60 L250,60" fill="none" stroke="#666" stroke-width="1.5" marker-end="url(#lfs)"/>
&lt;text x="165" y="54" text-anchor="middle" font-family="sans-serif" font-size="9.5" fill="#444">POST event batch&lt;/text>
&lt;path d="M250,85 L420,85" fill="none" stroke="#b35454" stroke-width="1.5" marker-end="url(#lfs)"/>
&lt;text x="335" y="79" text-anchor="middle" font-family="sans-serif" font-size="9.5" fill="#444">① writes raw event&lt;/text>
&lt;path d="M250,110 L560,110" fill="none" stroke="#c79a32" stroke-width="1.5" marker-end="url(#lfs)"/>
&lt;text x="405" y="104" text-anchor="middle" font-family="sans-serif" font-size="9.5" fill="#444">② queues REFERENCE (not the event)&lt;/text>
&lt;path d="M250,135 L80,135" fill="none" stroke="#3c8c54" stroke-width="1.8" marker-end="url(#lfs)"/>
&lt;text x="165" y="129" text-anchor="middle" font-family="sans-serif" font-size="9.5" font-weight="700" fill="#3c8c54">HTTP 207 (receipt) ~ms&lt;/text>
&lt;text x="165" y="160" text-anchor="middle" font-family="sans-serif" font-size="9" font-style="italic" fill="#999">— the client has already moved on —&lt;/text>
&lt;path d="M560,190 L700,190" fill="none" stroke="#666" stroke-width="1.5" marker-end="url(#lfs)"/>
&lt;text x="630" y="184" text-anchor="middle" font-family="sans-serif" font-size="9.5" fill="#444">③ Worker pulls ref&lt;/text>
&lt;path d="M700,215 L420,215" fill="none" stroke="#666" stroke-width="1.5" marker-end="url(#lfs)"/>
&lt;text x="560" y="209" text-anchor="middle" font-family="sans-serif" font-size="9.5" fill="#444">④ reads event from S3&lt;/text>
&lt;path d="M700,250 L700,265 L660,265" fill="none" stroke="#4a6fa5" stroke-width="1.5" marker-end="url(#lfs)"/>
&lt;text x="700" y="244" text-anchor="middle" font-family="sans-serif" font-size="9.5" fill="#444">⑤ buffer + batch flush → INSERT CH&lt;/text>
&lt;rect x="40" y="120" width="220" height="22" rx="4" fill="#dceede" stroke="#3c8c54" stroke-width="1" opacity="0.5"/>
&lt;rect x="610" y="178" width="160" height="100" rx="4" fill="#dde6f3" stroke="#4a6fa5" stroke-width="1" opacity="0.4"/>
&lt;text x="690" y="294" text-anchor="middle" font-family="sans-serif" font-size="9" font-style="italic" fill="#999">async, off the client path&lt;/text>
&lt;/svg>
&lt;/div>
&lt;p>The mathematical point is the &lt;strong>early ACK&lt;/strong>. The latency the client perceives when sending traces is:&lt;/p>
$$ t_{\text{client}} = t_{\text{S3 write}} + t_{\text{Redis enqueue}} \approx 10\text{–}40\,\text{ms} $$
&lt;p>whereas the real cost of persisting, reading S3, transforming, merging against the previous version, inserting into ClickHouse, letting the background merges compact, happens &lt;strong>off that path&lt;/strong>, in the Worker, and can take hundreds of ms or seconds without the client caring. The decoupling turns a system whose throughput would be limited by ClickHouse speed into one limited by Redis speed. And Redis, on modest hardware, sustains on the order of &lt;strong>50,000 operations/second&lt;/strong>.&lt;/p>
&lt;p>This has an important sizing consequence. If your inference load generates $E$ events/second (a chat with RAG + 2 tool calls easily produces 6-10 spans = events per request), the Web container absorbs them while $E \ll 50{,}000$. The Worker, by contrast, scales with the cost of &lt;em>processing&lt;/em>: that is the component to watch and replicate, and the first knob of the post.&lt;/p>
&lt;blockquote>
&lt;p>&lt;strong>Honest scepticism.&lt;/strong> The early ACK has a flip side: between the HTTP 207 and persistence in ClickHouse there is a &lt;strong>potential loss window&lt;/strong>. If the event is in S3 and the reference in Redis, and Redis goes down without persistence (AOF/RDB) before the Worker processes it, the reference is lost; the data is still in S3 but nobody claims it any more. More subtly: the Worker buffers writes to ClickHouse in memory and flushes them in batches; a Worker crash with a full buffer loses that batch. There is a &lt;a href="https://github.com/langfuse/langfuse/issues/13468">reported bug&lt;/a> where the &lt;code>ClickhouseWriter&lt;/code> discards rows after exhausting flush retries &lt;strong>with no dead-letter queue&lt;/strong>. For observability this is usually tolerable (losing 0.01 % of the traces breaks nothing). For &lt;em>regulatory auditing&lt;/em>, where the trace is evidence, it is not, and Langfuse is better treated as &amp;ldquo;best-effort&amp;rdquo; and not as a ledger. We will come back to this at the close.&lt;/p>
&lt;/blockquote>
&lt;h2 id="interaction-with-the-rest-of-the-stack-langfuse-in-the-example-4h100-cluster">Interaction with the rest of the stack: Langfuse in the example 4×H100 cluster&lt;/h2>
&lt;p>Langfuse does not live in isolation. In the &lt;a href="https://blog.lo0.es/en/posts/anatomy-on-premise-llm-inference-stack-seven-layers/">seven-layer stack&lt;/a> it occupies the LLM-aware observability layer, and it relates to almost every other one. On the generic reference cluster we use throughout the blog, &lt;strong>4×H100 SXM 80 GB (320 GB aggregate VRAM), NVLink, 640 GB of system RAM, NVMe-oF, 25/100 GbE network&lt;/strong>, the telemetry flow looks like this:&lt;/p>
&lt;div class="diagram" style="max-width:840px;margin:1.2rem auto;">
&lt;svg viewBox="0 0 840 420" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Interaction of Langfuse with the inference stack on a 4xH100 cluster">
&lt;defs>&lt;marker id="lfx" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto">&lt;path d="M0,0 L10,5 L0,10 z" fill="#666"/>&lt;/marker>&lt;/defs>
&lt;text x="420" y="24" text-anchor="middle" font-family="sans-serif" font-size="14" font-weight="700" fill="currentColor">Data plane vs telemetry plane · 4×H100 SXM cluster&lt;/text>
&lt;!-- Data plane -->
&lt;rect x="24" y="44" width="430" height="356" rx="10" fill="none" stroke="#4a6fa5" stroke-width="1.4" stroke-dasharray="6 3"/>
&lt;text x="239" y="62" text-anchor="middle" font-family="sans-serif" font-size="11.5" font-weight="700" fill="#4a6fa5">Data plane (serves tokens · hot path)&lt;/text>
&lt;rect x="50" y="78" width="170" height="50" rx="7" fill="#eef3fb" stroke="#4a6fa5" stroke-width="1.4"/>
&lt;text x="135" y="98" text-anchor="middle" font-family="sans-serif" font-size="11" font-weight="700" fill="#222">App / Agent&lt;/text>
&lt;text x="135" y="115" text-anchor="middle" font-family="sans-serif" font-size="9" fill="#444">Langfuse / OTel SDK&lt;/text>
&lt;rect x="50" y="150" width="170" height="50" rx="7" fill="#eef3fb" stroke="#4a6fa5" stroke-width="1.4"/>
&lt;text x="135" y="170" text-anchor="middle" font-family="sans-serif" font-size="11" font-weight="700" fill="#222">L7 Gateway&lt;/text>
&lt;text x="135" y="187" text-anchor="middle" font-family="sans-serif" font-size="9" fill="#444">router / LiteLLM&lt;/text>
&lt;rect x="50" y="222" width="170" height="58" rx="7" fill="#dceede" stroke="#3c8c54" stroke-width="1.6"/>
&lt;text x="135" y="244" text-anchor="middle" font-family="sans-serif" font-size="11" font-weight="700" fill="#222">vLLM (TP=4)&lt;/text>
&lt;text x="135" y="261" text-anchor="middle" font-family="sans-serif" font-size="9" fill="#444">general LLM · H100×4&lt;/text>
&lt;rect x="252" y="222" width="180" height="58" rx="7" fill="#dceede" stroke="#3c8c54" stroke-width="1.6"/>
&lt;text x="342" y="240" text-anchor="middle" font-family="sans-serif" font-size="10.5" font-weight="700" fill="#222">Embeddings + Reranker&lt;/text>
&lt;text x="342" y="256" text-anchor="middle" font-family="sans-serif" font-size="9" fill="#444">MIG slices&lt;/text>
&lt;text x="342" y="270" text-anchor="middle" font-family="sans-serif" font-size="9" fill="#444">+ vector store&lt;/text>
&lt;rect x="50" y="306" width="382" height="42" rx="7" fill="#f7efda" stroke="#c79a32" stroke-width="1.4"/>
&lt;text x="241" y="332" text-anchor="middle" font-family="sans-serif" font-size="10" fill="#333">Guardrails · semantic cache · tool services&lt;/text>
&lt;!-- OTel Collector centre -->
&lt;rect x="486" y="150" width="150" height="74" rx="9" fill="#f0e6d2" stroke="#b58a2e" stroke-width="1.8"/>
&lt;text x="561" y="178" text-anchor="middle" font-family="sans-serif" font-size="11" font-weight="700" fill="#222">OTel Collector&lt;/text>
&lt;text x="561" y="195" text-anchor="middle" font-family="sans-serif" font-size="9" fill="#444">batch · tail-sampling&lt;/text>
&lt;text x="561" y="209" text-anchor="middle" font-family="sans-serif" font-size="9" fill="#444">attributes (tenant_id)&lt;/text>
&lt;!-- telemetry plane -->
&lt;rect x="660" y="44" width="160" height="356" rx="10" fill="none" stroke="#7a5aa5" stroke-width="1.4" stroke-dasharray="6 3"/>
&lt;text x="740" y="62" text-anchor="middle" font-family="sans-serif" font-size="11.5" font-weight="700" fill="#7a5aa5">Telemetry (cold)&lt;/text>
&lt;rect x="676" y="80" width="128" height="120" rx="9" fill="#e6ddf3" stroke="#7a5aa5" stroke-width="1.8"/>
&lt;text x="740" y="104" text-anchor="middle" font-family="sans-serif" font-size="12" font-weight="700" fill="#222">Langfuse&lt;/text>
&lt;text x="740" y="124" text-anchor="middle" font-family="sans-serif" font-size="9" fill="#444">Web + Worker&lt;/text>
&lt;text x="740" y="140" text-anchor="middle" font-family="sans-serif" font-size="9" fill="#444">CH · PG · Redis&lt;/text>
&lt;text x="740" y="156" text-anchor="middle" font-family="sans-serif" font-size="9" fill="#444">MinIO&lt;/text>
&lt;text x="740" y="178" text-anchor="middle" font-family="sans-serif" font-size="8.5" font-style="italic" fill="#777">dedicated CPU node&lt;/text>
&lt;text x="740" y="191" text-anchor="middle" font-family="sans-serif" font-size="8.5" font-style="italic" fill="#777">(off the GPUs)&lt;/text>
&lt;rect x="676" y="220" width="128" height="50" rx="8" fill="#dde6f3" stroke="#4a6fa5" stroke-width="1.3"/>
&lt;text x="740" y="242" text-anchor="middle" font-family="sans-serif" font-size="10" font-weight="700" fill="#222">Tempo&lt;/text>
&lt;text x="740" y="258" text-anchor="middle" font-family="sans-serif" font-size="8.5" fill="#444">infra spans&lt;/text>
&lt;rect x="676" y="288" width="128" height="50" rx="8" fill="#dde6f3" stroke="#4a6fa5" stroke-width="1.3"/>
&lt;text x="740" y="310" text-anchor="middle" font-family="sans-serif" font-size="10" font-weight="700" fill="#222">Prometheus&lt;/text>
&lt;text x="740" y="326" text-anchor="middle" font-family="sans-serif" font-size="8.5" fill="#444">DCGM · vLLM&lt;/text>
&lt;!-- data arrows -->
&lt;path d="M135,128 L135,150" fill="none" stroke="#4a6fa5" stroke-width="1.6" marker-end="url(#lfx)"/>
&lt;path d="M135,200 L135,222" fill="none" stroke="#4a6fa5" stroke-width="1.6" marker-end="url(#lfx)"/>
&lt;path d="M220,251 L252,251" fill="none" stroke="#4a6fa5" stroke-width="1.4" marker-end="url(#lfx)"/>
&lt;!-- spans to collector -->
&lt;path d="M220,160 C360,150 420,180 486,180" fill="none" stroke="#b58a2e" stroke-width="1.5" marker-end="url(#lfx)" stroke-dasharray="4 2"/>
&lt;text x="350" y="146" text-anchor="middle" font-family="sans-serif" font-size="9" fill="#b58a2e">gen_ai.* spans&lt;/text>
&lt;path d="M220,250 C400,300 430,210 486,200" fill="none" stroke="#b58a2e" stroke-width="1.3" marker-end="url(#lfx)" stroke-dasharray="4 2"/>
&lt;!-- collector to backends -->
&lt;path d="M636,170 L676,130" fill="none" stroke="#7a5aa5" stroke-width="1.6" marker-end="url(#lfx)"/>
&lt;text x="660" y="138" text-anchor="middle" font-family="sans-serif" font-size="8.5" fill="#7a5aa5">LLM traces&lt;/text>
&lt;path d="M636,200 L676,240" fill="none" stroke="#4a6fa5" stroke-width="1.4" marker-end="url(#lfx)"/>
&lt;path d="M636,210 L676,305" fill="none" stroke="#4a6fa5" stroke-width="1.2" marker-end="url(#lfx)"/>
&lt;/svg>
&lt;/div>
&lt;p>Three ideas from this topology:&lt;/p>
&lt;ol>
&lt;li>
&lt;p>&lt;strong>Langfuse receives from the OTel Collector, not from the application directly&lt;/strong> (in the recommended pattern). The app SDK or vLLM emit spans with the &lt;code>gen_ai.*&lt;/code> semantic conventions; the Collector does &lt;code>batch&lt;/code>, &lt;code>tail-sampling&lt;/code> (preserving 100 % of errors and high latencies, sampling the rest) and enriches with its own attributes (&lt;code>tenant_id&lt;/code>, &lt;code>priority_tier&lt;/code>); and it &lt;em>distributes&lt;/em>: LLM traces go to Langfuse, infrastructure spans to Tempo, metrics (GPU DCGM, vLLM metrics) to Prometheus. Langfuse is &lt;strong>one more exporter&lt;/strong>, not the only destination. This is covered in detail in &lt;a href="https://blog.lo0.es/en/posts/llm-tracing-opentelemetry-genai/">the OTel tracing post&lt;/a>.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;strong>Langfuse runs off the GPUs.&lt;/strong> It is a consumer of CPU, RAM, disk and network (ClickHouse wants memory, MinIO wants disk, Redis wants CPU for networking) but &lt;strong>it does not touch VRAM&lt;/strong>. On the 4×H100 cluster, Langfuse lives on a CPU node (or on the GPU nodes but with &lt;code>nodeSelector&lt;/code>/&lt;code>taints&lt;/code> keeping it away from the vLLM pods). Mixing ClickHouse with vLLM on the same node without resource limits is asking for an ingestion spike to steal memory bandwidth from inference. Isolation by design.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;strong>The telemetry route is &amp;ldquo;cold&amp;rdquo; and the data route is &amp;ldquo;hot&amp;rdquo;.&lt;/strong> The data plane (left) serves tokens with a millisecond latency budget; the telemetry plane (right) tolerates seconds. The early ingestion ACK is what keeps these two clocks apart: the app does not wait for Langfuse to file anything before returning the response to the user.&lt;/p>
&lt;/li>
&lt;/ol>
&lt;h2 id="the-10-backend-knobs-that-move-the-needle-most">The 10 backend knobs that move the needle most&lt;/h2>
&lt;p>These are, in approximate order of impact/frequency, the settings that decide whether your self-hosted Langfuse ingests 50 events/s or 5,000, and whether your disk grows sustainably or explodes in three weeks. They are all environment variables or config injected into the &lt;strong>Web and Worker&lt;/strong> containers (except the ClickHouse ones, which go in its server-side config). The canonical detail is in the &lt;a href="https://langfuse.com/self-hosting/configuration/scaling">Langfuse scaling doc&lt;/a>.&lt;/p>
&lt;h3 id="knob-1--scale-the-worker-by-load-the-first-lever-always">Knob 1 — Scale the Worker by load (the first lever, always)&lt;/h3>
&lt;p>The Worker is the component that saturates first, because it does the expensive work: reading S3, transforming, merging, inserting into ClickHouse. The Langfuse operational rule is simple: &lt;strong>a 2-CPU Worker container above 50 % CPU use is saturated&lt;/strong>; add replicas. Better than CPU, the Worker publishes via statsd the metric &lt;code>langfuse.queue.ingestion.length&lt;/code> (ingestion queue length), which is the direct signal for autoscaling: if the queue grows without draining, there are not enough Workers.&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-yaml" data-lang="yaml">&lt;span class="line">&lt;span class="cl">&lt;span class="c"># The ideal autoscaler watches queue depth, not just CPU.&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="c"># (KEDA ScaledObject over the statsd metric → Prometheus)&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">triggers&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">type&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">prometheus&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">metadata&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">query&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">langfuse_queue_ingestion_length&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">threshold&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;10000&amp;#34;&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c"># if the queue goes past 10k refs, scale&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>In AWS deployments there is &lt;code>ENABLE_AWS_CLOUDWATCH_METRIC_PUBLISHING=true&lt;/code> to push these metrics to CloudWatch. On-premise, the path is statsd → Prometheus → KEDA, slotting into the &lt;a href="https://blog.lo0.es/en/posts/llm-inference-autoscaling-kubernetes-hpa-keda/">autoscaling on Kubernetes with KEDA&lt;/a> we already covered for vLLM. &lt;strong>Always start here&lt;/strong>: most &amp;ldquo;Langfuse is slow&amp;rdquo; problems are simply insufficient Workers, not fine tuning.&lt;/p>
&lt;h3 id="knob-2--separate-the-ingestion-deployment-from-the-ui-one">Knob 2 — Separate the ingestion deployment from the UI one&lt;/h3>
&lt;p>When ingestion is heavily loaded, UI queries and the public API become slow because they share the same Web container. The solution is to &lt;strong>split langfuse-web into two identical deployments&lt;/strong> and route by path: everything under &lt;code>/api/public/ingestion*&lt;/code>, &lt;code>/api/public/media*&lt;/code> and &lt;code>/api/public/otel*&lt;/code> goes to the ingestion deployment; the rest (UI, read API) to the interface one.&lt;/p>
&lt;pre tabindex="0">&lt;code># Ingress / gateway rule
location ~ ^/api/public/(ingestion|media|otel) {
proxy_pass http://langfuse-web-ingest; # replicas dedicated to writing
}
location / {
proxy_pass http://langfuse-web-ui; # replicas dedicated to reading
}
&lt;/code>&lt;/pre>&lt;p>It is the same idea as the read/write separation of any system with mixed loads: a storm of writes should not starve whoever is trying to &lt;em>look at&lt;/em> the dashboard right in the middle of the incident, which is precisely when they need it most.&lt;/p>
&lt;h3 id="knob-3--batching-writes-to-clickhouse-interval--batch-size">Knob 3 — Batching writes to ClickHouse (interval + batch size)&lt;/h3>
&lt;p>ClickHouse hates small, frequent inserts: every &lt;code>INSERT&lt;/code> creates a &lt;em>part&lt;/em> on disk that then has to be merged, and thousands of tiny inserts generate thousands of parts and a storm of background merges that saturates the disk. The defence is to &lt;strong>accumulate in an in-memory buffer in the Worker and flush in large batches&lt;/strong>:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># Worker: fewer flushes, larger batches → fewer parts, fewer merges&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="nv">LANGFUSE_INGESTION_CLICKHOUSE_WRITE_INTERVAL_MS&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="m">1000&lt;/span> &lt;span class="c1"># raise e.g. to 2000-5000&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="nv">LANGFUSE_INGESTION_CLICKHOUSE_WRITE_BATCH_SIZE&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="m">10000&lt;/span> &lt;span class="c1"># raise if there is throughput&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Raising the interval and the batch size &lt;strong>reduces the flush frequency&lt;/strong> and improves sustained throughput. The trade-off is direct and has to be understood: larger, less frequent batches mean &lt;strong>more data in the Worker&amp;rsquo;s volatile buffer&lt;/strong>, that is, a bigger loss window if the Worker goes down (a knob coupled to the scepticism at the close). Langfuse also uses ClickHouse &lt;code>async_insert&lt;/code>, which accumulates server-side before confirming; it adds another layer of buffering to keep in mind.&lt;/p>
&lt;h3 id="knob-4--skip-the-clickhouse-read-before-write-in-ingestion">Knob 4 — Skip the ClickHouse read-before-write in ingestion&lt;/h3>
&lt;p>By default, when ingesting an event the Worker &lt;strong>reads the existing event from ClickHouse and merges it&lt;/strong> with the incoming one (necessary when legacy SDKs send partial events: a &lt;code>start&lt;/code>, then an &lt;code>end&lt;/code>, then an &lt;code>update&lt;/code> of the same observation). That per-event read loads ClickHouse on the write path and limits total throughput.&lt;/p>
&lt;p>If your projects are not migrated from an old version, because the full history already lives in S3, you can &lt;strong>disable that read&lt;/strong>:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># Date earlier than the creation of your first project&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="nv">LANGFUSE_SKIP_INGESTION_CLICKHOUSE_READ_MIN_PROJECT_CREATE_DATE&lt;/span>&lt;span class="o">=&lt;/span>2025-01-01
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>With modern Langfuse SDKs or with ingestion via OpenTelemetry, this does not affect you negatively and removes one read per event. A warning from the doc itself: if you combine this with aggressive deletion (lifecycle) rules in S3 plus late event updates, you can generate duplicates in the history. Know that before enabling it.&lt;/p>
&lt;h3 id="knob-5--write-concurrency-to-s3blob-storage">Knob 5 — Write concurrency to S3/Blob storage&lt;/h3>
&lt;p>In high-throughput scenarios, the S3 client can &lt;strong>exhaust its sockets&lt;/strong> and start queueing and throttling writes. The symptom is unmistakable in the logs of the Web container that handles ingestion:&lt;/p>
&lt;pre tabindex="0">&lt;code>@smithy/node-http-handler:WARN - socket usage at capacity=150
and 387 additional requests are enqueued.
&lt;/code>&lt;/pre>&lt;p>…accompanied by a rise in memory in that container (queued requests pile up in RAM). The cure is to raise the concurrent write limit from its default of 50:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="cl">&lt;span class="nv">LANGFUSE_S3_CONCURRENT_WRITES&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="m">100&lt;/span> &lt;span class="c1"># raise gradually from 50&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Each additional socket has a small memory cost, so the official advice is to raise it &lt;strong>gradually&lt;/strong> while observing the behaviour, not to jump to 1000 in one go.&lt;/p>
&lt;h3 id="knob-6--redis-queue-sharding--concurrency-per-shard">Knob 6 — Redis queue sharding + concurrency &lt;em>per shard&lt;/em>&lt;/h3>
&lt;p>If Redis goes past 90 % CPU, first the obvious: an instance with &lt;strong>at least 4 CPUs&lt;/strong> (so Redis spreads networking and background tasks across different cores) and &lt;strong>Redis Cluster mode&lt;/strong> enabled. If CPU still does not come down, you can &lt;strong>shard the queues&lt;/strong> Langfuse uses:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># Advanced: only if Redis is genuinely drowning and you already did the above&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="nv">LANGFUSE_INGESTION_QUEUE_SHARD_COUNT&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="m">6&lt;/span> &lt;span class="c1"># ~2-3× the number of Redis cluster shards&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="nv">LANGFUSE_TRACE_UPSERT_QUEUE_SHARD_COUNT&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="m">6&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># Concurrency counts PER SHARD; target ~20 per worker&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="nv">LANGFUSE_INGESTION_QUEUE_PROCESSING_CONCURRENCY&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="m">3&lt;/span> &lt;span class="c1"># 6 shards × ~3 ≈ 18&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="nv">LANGFUSE_TRACE_UPSERT_WORKER_CONCURRENCY&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="m">3&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Two traps the doc underlines and that are worth tattooing: &lt;strong>once you shard, do not reduce the number of shards&lt;/strong> (it breaks the distribution); and concurrency counts &lt;strong>per shard&lt;/strong>, not globally, so if you have 10 shards and want concurrency 20 per worker, set &lt;code>2&lt;/code>, not &lt;code>20&lt;/code>. It is an advanced knob: most on-premise deployments never need it.&lt;/p>
&lt;h3 id="knob-7--the-final-modifier-for-otel-only-projects">Knob 7 — The &lt;code>FINAL&lt;/code> modifier for OTel-only projects&lt;/h3>
&lt;p>Langfuse stores observations in a ClickHouse &lt;code>ReplacingMergeTree&lt;/code> and, by default, adds the &lt;code>FINAL&lt;/code> modifier to API queries so the last version of each row wins at read time. &lt;code>FINAL&lt;/code> is necessary when ingestion produces several versions of the same observation (legacy SDKs with their &lt;code>start&lt;/code>/&lt;code>end&lt;/code>/&lt;code>update&lt;/code> events), but &lt;strong>it adds merge work to every read and slows it down&lt;/strong>.&lt;/p>
&lt;p>Projects that ingest &lt;strong>exclusively over OpenTelemetry&lt;/strong> write each observation as a single immutable row, so &lt;code>FINAL&lt;/code> is redundant for them:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># Recommended in mixed deployments: per-project, flagged in Redis with a 24h TTL&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="nv">LANGFUSE_SKIP_FINAL_FOR_OTEL_PROJECTS&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="nb">true&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"># Only if ALL projects are OTel-only: global, no Redis lookup&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="nv">LANGFUSE_API_CLICKHOUSE_DISABLE_OBSERVATIONS_FINAL&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="nb">true&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Since on the example cluster the instrumentation is 100 % OTel (&lt;code>gen_ai.*&lt;/code> via Collector), this knob is &lt;strong>free money in dashboard read latency&lt;/strong>. Careful with the global version: do not enable it if some project is still using legacy ingestion, or reads may return duplicate or stale rows.&lt;/p>
&lt;h3 id="knob-8--separate-analytical-reads-from-the-write-path-compute-compute">Knob 8 — Separate analytical reads from the write path (compute-compute)&lt;/h3>
&lt;p>Heavy dashboard queries (percentiles over millions of spans) compete with ingestion inserts and with background merges on the &lt;em>same&lt;/em> ClickHouse. If your deployment supports &lt;strong>compute-compute separation&lt;/strong> (ClickHouse Cloud or BYOC), you can route reads to a read-only compute group:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="cl">&lt;span class="nv">CLICKHOUSE_URL&lt;/span>&lt;span class="o">=&lt;/span>http://clickhouse-primary:8123 &lt;span class="c1"># writes, migrations, ingestion&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="nv">CLICKHOUSE_READ_ONLY_URL&lt;/span>&lt;span class="o">=&lt;/span>http://clickhouse-reader:8123 &lt;span class="c1"># UI + public API reads&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;strong>Critical nuance for on-premise&lt;/strong>, and here it is time to be sceptical about the usefulness of this knob in our context: on a &lt;strong>single-node&lt;/strong> ClickHouse or on a self-managed cluster without compute separation, this variable &lt;strong>adds nothing&lt;/strong>, because the read endpoint would be the same as the write one. It is a knob for cloud architectures with storage separated from compute. On an on-premise 4×H100 cluster with ClickHouse on one node, the real alternative is to &lt;strong>scale ClickHouse vertically&lt;/strong> (the doc recommends ≥16 GiB of RAM for large deployments; ClickHouse scales vertically well) and to make sure &lt;strong>every query filters by &lt;code>projectId&lt;/code> and time&lt;/strong>, which is how the tables are indexed. Without a time filter, even the fattest ClickHouse suffers.&lt;/p>
&lt;h3 id="knob-9--data-retention-ttl-in-clickhouse--lifecycle-in-s3">Knob 9 — Data retention: TTL in ClickHouse + lifecycle in S3&lt;/h3>
&lt;p>Disk is the cost that grows on its own. LLM traces carry whole inputs and outputs (sometimes prompts of tens of KB), and ClickHouse also accumulates its own system tables. The first-order lever is a &lt;strong>retention policy&lt;/strong> that nightly deletes traces, observations, scores and media older than N days, coordinating ClickHouse and blob storage. Where the retention feature is not available, it is done by hand:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-sql" data-lang="sql">&lt;span class="line">&lt;span class="cl">&lt;span class="c1">-- ClickHouse: TTL over the tracing tables
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="k">ALTER&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">TABLE&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">traces&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">MODIFY&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">TTL&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">toDateTime&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="k">timestamp&lt;/span>&lt;span class="p">)&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">+&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nb">INTERVAL&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="mi">90&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">DAY&lt;/span>&lt;span class="p">;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="k">ALTER&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">TABLE&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">observations&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">MODIFY&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">TTL&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">toDateTime&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">start_time&lt;/span>&lt;span class="p">)&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">+&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nb">INTERVAL&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="mi">90&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">DAY&lt;/span>&lt;span class="p">;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="k">ALTER&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">TABLE&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">scores&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">MODIFY&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">TTL&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">toDateTime&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="k">timestamp&lt;/span>&lt;span class="p">)&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">+&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nb">INTERVAL&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="mi">90&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">DAY&lt;/span>&lt;span class="p">;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="k">ALTER&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">TABLE&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">event_log&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">MODIFY&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">TTL&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">toDateTime&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="k">timestamp&lt;/span>&lt;span class="p">)&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">+&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nb">INTERVAL&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="mi">30&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">DAY&lt;/span>&lt;span class="p">;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;pre tabindex="0">&lt;code># S3/MinIO: lifecycle rule, e.g. 30 days for the raw event bucket
# WATCH OUT! Do NOT apply retention to the MEDIA bucket:
# - it breaks the files referenced in traces
# - it breaks future uploads (state is tracked by hash in Postgres)
&lt;/code>&lt;/pre>&lt;p>Two operational parameters that avoid nasty surprises on large deletions:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="cl">&lt;span class="nv">LANGFUSE_CLICKHOUSE_DELETION_TIMEOUT_MS&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="m">600000&lt;/span> &lt;span class="c1"># default 10 min; raise it if deletions time out&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># ClickHouse 25.7+: less mutation pressure on bulk deletions&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="nv">CLICKHOUSE_LIGHTWEIGHT_DELETE_MODE&lt;/span>&lt;span class="o">=&lt;/span>lightweight_update
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="nv">CLICKHOUSE_USE_LIGHTWEIGHT_UPDATE&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="nb">true&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>The mental rule: &lt;strong>short retention for raw events&lt;/strong> (S3, 30 days is usually enough, they are recoverable/recomputable), &lt;strong>retention by business value for the ClickHouse tables&lt;/strong> (90 days, 180, whatever compliance asks for), and &lt;strong>never touch the media bucket with blind lifecycle rules&lt;/strong>.&lt;/p>
&lt;h3 id="knob-10--hygiene-of-the-clickhouse-system-log-tables-the-silent-disk-killer">Knob 10 — Hygiene of the ClickHouse &lt;em>system log tables&lt;/em> (the silent disk killer)&lt;/h3>
&lt;p>This is the knob nobody configures and that fills the disk without showing up in any Langfuse metric, because &lt;strong>it is not Langfuse data&lt;/strong>: they are the system tables of ClickHouse itself (&lt;code>trace_log&lt;/code>, &lt;code>text_log&lt;/code>, &lt;code>opentelemetry_span_log&lt;/code>, &lt;code>asynchronous_metric_log&lt;/code>, &lt;code>metric_log&lt;/code>, &lt;code>latency_log&lt;/code>). By default &lt;strong>they have no TTL&lt;/strong>, and the query profiler writes to &lt;code>system.trace_log&lt;/code> continuously. On a ClickHouse with traffic, these tables can &lt;strong>dominate disk usage&lt;/strong> while you look for the problem in your traces. Langfuse does not read from them, so they can be trimmed without fear. Two options:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-xml" data-lang="xml">&lt;span class="line">&lt;span class="cl">&lt;span class="c">&amp;lt;!-- Option A — disable the ones Langfuse never reads
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c"> (file in /etc/clickhouse-server/config.d/) --&amp;gt;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="nt">&amp;lt;clickhouse&amp;gt;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;lt;trace_log&lt;/span> &lt;span class="na">remove=&lt;/span>&lt;span class="s">&amp;#34;1&amp;#34;&lt;/span>&lt;span class="nt">/&amp;gt;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;lt;text_log&lt;/span> &lt;span class="na">remove=&lt;/span>&lt;span class="s">&amp;#34;1&amp;#34;&lt;/span>&lt;span class="nt">/&amp;gt;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;lt;opentelemetry_span_log&lt;/span> &lt;span class="na">remove=&lt;/span>&lt;span class="s">&amp;#34;1&amp;#34;&lt;/span>&lt;span class="nt">/&amp;gt;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;lt;asynchronous_metric_log&lt;/span> &lt;span class="na">remove=&lt;/span>&lt;span class="s">&amp;#34;1&amp;#34;&lt;/span>&lt;span class="nt">/&amp;gt;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;lt;metric_log&lt;/span> &lt;span class="na">remove=&lt;/span>&lt;span class="s">&amp;#34;1&amp;#34;&lt;/span>&lt;span class="nt">/&amp;gt;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;lt;latency_log&lt;/span> &lt;span class="na">remove=&lt;/span>&lt;span class="s">&amp;#34;1&amp;#34;&lt;/span>&lt;span class="nt">/&amp;gt;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="nt">&amp;lt;/clickhouse&amp;gt;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c">&amp;lt;!-- Keep query_log, part_log and error_log: useful for debugging and small --&amp;gt;&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-sql" data-lang="sql">&lt;span class="line">&lt;span class="cl">&lt;span class="c1">-- Option B — aggressive TTL + turn off the profiler, if you want to keep them for debugging
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1">-- (in config: query_profiler_real_time_period_ns = 0)
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="k">SET&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">max_table_size_to_drop&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="mi">0&lt;/span>&lt;span class="p">;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="k">TRUNCATE&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">TABLE&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">system&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">trace_log&lt;/span>&lt;span class="p">;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="k">ALTER&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">TABLE&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">system&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">trace_log&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">MODIFY&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">TTL&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">event_date&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">+&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nb">INTERVAL&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="mi">7&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">DAY&lt;/span>&lt;span class="p">;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="c1">-- repeat for each log table to cap
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>To identify which table is eating the disk, the golden query:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-sql" data-lang="sql">&lt;span class="line">&lt;span class="cl">&lt;span class="k">SELECT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">table&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">formatReadableSize&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="k">sum&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">bytes&lt;/span>&lt;span class="p">))&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">AS&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">size&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">sum&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="k">rows&lt;/span>&lt;span class="p">)&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">AS&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">rows&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="k">FROM&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">system&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">parts&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">WHERE&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">active&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">GROUP&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">BY&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">table&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">ORDER&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">BY&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">sum&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">bytes&lt;/span>&lt;span class="p">)&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">DESC&lt;/span>&lt;span class="p">;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>If you take only one knob from this post to your first real deployment, make it this one: the difference between a ClickHouse that grows 2 GB/day of useful data and one that grows 20 GB/day of system logs nobody looks at.&lt;/p>
&lt;h3 id="summary-table-of-the-10-knobs">Summary table of the 10 knobs&lt;/h3>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>#&lt;/th>
&lt;th>Knob&lt;/th>
&lt;th>Variable / action&lt;/th>
&lt;th>When&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>1&lt;/td>
&lt;td>Scale Worker&lt;/td>
&lt;td>replicas by CPU&amp;gt;50 % / &lt;code>langfuse.queue.ingestion.length&lt;/code>&lt;/td>
&lt;td>always, first&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>2&lt;/td>
&lt;td>Separate ingestion/UI&lt;/td>
&lt;td>route &lt;code>/ingestion*&lt;/code>,&lt;code>/media*&lt;/code>,&lt;code>/otel*&lt;/code> to a dedicated replica&lt;/td>
&lt;td>slow UI under load&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>3&lt;/td>
&lt;td>Batching to ClickHouse&lt;/td>
&lt;td>&lt;code>LANGFUSE_INGESTION_CLICKHOUSE_WRITE_INTERVAL_MS&lt;/code> / &lt;code>_BATCH_SIZE&lt;/code>&lt;/td>
&lt;td>high throughput&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>4&lt;/td>
&lt;td>Skip CH read-before-write&lt;/td>
&lt;td>&lt;code>LANGFUSE_SKIP_INGESTION_CLICKHOUSE_READ_MIN_PROJECT_CREATE_DATE&lt;/code>&lt;/td>
&lt;td>non-migrated projects&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>5&lt;/td>
&lt;td>S3 concurrency&lt;/td>
&lt;td>&lt;code>LANGFUSE_S3_CONCURRENT_WRITES&lt;/code> (def. 50)&lt;/td>
&lt;td>&amp;ldquo;socket usage at capacity&amp;rdquo;&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>6&lt;/td>
&lt;td>Redis queue sharding&lt;/td>
&lt;td>&lt;code>LANGFUSE_*_QUEUE_SHARD_COUNT&lt;/code> + &lt;code>*_CONCURRENCY&lt;/code> (per shard)&lt;/td>
&lt;td>Redis CPU &amp;gt;90 %&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>7&lt;/td>
&lt;td>Remove &lt;code>FINAL&lt;/code> (OTel)&lt;/td>
&lt;td>&lt;code>LANGFUSE_SKIP_FINAL_FOR_OTEL_PROJECTS=true&lt;/code>&lt;/td>
&lt;td>100 % OTel instrumentation&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>8&lt;/td>
&lt;td>CH read/write split&lt;/td>
&lt;td>&lt;code>CLICKHOUSE_READ_ONLY_URL&lt;/code> (cloud/BYOC only)&lt;/td>
&lt;td>compute-compute available&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>9&lt;/td>
&lt;td>Retention + TTL&lt;/td>
&lt;td>TTL in CH + S3 lifecycle + &lt;code>LANGFUSE_CLICKHOUSE_DELETION_TIMEOUT_MS&lt;/code>&lt;/td>
&lt;td>always (disk cost)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>10&lt;/td>
&lt;td>CH system log hygiene&lt;/td>
&lt;td>&lt;code>&amp;lt;trace_log remove=&amp;quot;1&amp;quot;/&amp;gt;&lt;/code> or aggressive TTL&lt;/td>
&lt;td>always (hidden disk)&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;h2 id="how-to-maximise-langfuse-on-the-example-4h100-cluster">How to maximise Langfuse on the example 4×H100 cluster&lt;/h2>
&lt;p>With the architecture and the knobs clear, this is a concrete sizing to get the most out of Langfuse on the generic reference cluster (&lt;strong>4×H100 SXM, 320 GB VRAM, 640 GB RAM, NVMe-oF, 25/100 GbE&lt;/strong>), without stealing a single GB of VRAM from inference.&lt;/p>
&lt;h3 id="component-distribution">Component distribution&lt;/h3>
&lt;p>Langfuse is &lt;strong>100 % CPU/RAM/disk/network load&lt;/strong>, so its natural place is &lt;strong>off the GPU nodes&lt;/strong> or, if it cohabits, with &lt;code>taints&lt;/code>/&lt;code>nodeSelector&lt;/code> confining it away from the vLLM pods. Suggested distribution:&lt;/p>
&lt;pre tabindex="0">&lt;code>nodo-cpu-01 (control + observability, no GPU)
├── langfuse-web-ingest ×3 (2 CPU / 4 GiB each) ← ingestion, scales with load
├── langfuse-web-ui ×2 (2 CPU / 4 GiB each) ← dashboard/read API
├── langfuse-worker ×4 (2 CPU / 4 GiB each) ← the one that scales most
├── redis/valkey ×1 (4 CPU / 4 GiB, cluster mode)
└── postgres ×1 (2 CPU / 8 GiB, replica for HA)
nodo-storage-01 (heavy state, local NVMe)
├── clickhouse ×1 (8 CPU / 32 GiB / NVMe) ← ≥16 GiB is the minimum; 32 is comfortable
└── minio (S3) ×1 (4 CPU / 8 GiB / HDD+NVMe cache)
nodo-gpu-01..02 (4×H100 SXM each) → inference ONLY
└── vLLM, embeddings, reranker, guardrails (emit spans, do not host Langfuse)
&lt;/code>&lt;/pre>&lt;h3 id="sizing-by-real-load">Sizing by real load&lt;/h3>
&lt;p>Let us put numbers on an example load. Suppose the cluster serving &lt;strong>300 requests/second&lt;/strong> of chat-with-RAG, where each request generates on the order of &lt;strong>8 spans&lt;/strong> (request, retrieval, rerank, 2× tool, guardrail in, llm, guardrail out):&lt;/p>
$$ E = 300\,\tfrac{\text{req}}{\text{s}} \times 8\,\tfrac{\text{spans}}{\text{req}} = 2,400\ \text{events/s} $$
&lt;p>Against the Redis ceiling (~50,000 ops/s), $E = 2{,}400$ leaves the reception counter at &lt;strong>~5 % of its capacity&lt;/strong>: enormous headroom. The component to watch is the Worker. With a target of ~20 concurrency per Worker and batches of 10,000 events every ~1-2 s, 4 Workers drain 2,400 ev/s with margin; the &lt;code>langfuse.queue.ingestion.length&lt;/code> metric should stay flat near zero. If it grows, knob 1 (more Workers) is the answer before any fine tuning.&lt;/p>
&lt;p>&lt;strong>Tail-sampling is the multiplier that changes the economics.&lt;/strong> If the Collector preserves 100 % of errors/high latencies but samples normal traffic at, say, 10 %, the 2,400 ev/s you &lt;em>store&lt;/em> in ClickHouse drop to ~240-300 effective ev/s without losing the signal that matters. The rule: &lt;strong>sample in the Collector, not in Langfuse&lt;/strong>. Langfuse should receive, already filtered, what deserves to be persisted. This is developed in &lt;a href="https://blog.lo0.es/en/posts/llm-tracing-opentelemetry-genai/">the OTel tracing post&lt;/a>; here it is enough to note that upstream sampling is, in effect, the knob 0 that multiplies all the others.&lt;/p>
&lt;h3 id="storage-estimate">Storage estimate&lt;/h3>
&lt;p>An LLM observation with full input+output weighs, compressed in ClickHouse, on the order of &lt;strong>1-3 KB&lt;/strong> (ClickHouse compresses text very well, 5-10×). With sampling at 10 % over 2,400 ev/s:&lt;/p>
$$ 240\,\tfrac{\text{ev}}{\text{s}} \times 2\,\text{KB} \times 86,400\,\tfrac{\text{s}}{\text{day}} \approx 41\ \text{GB/day (raw)} \;\xrightarrow{\text{compression}}\; \sim 5\text{–}8\ \text{GB/day in CH} $$
&lt;p>At 90 days of retention (knob 9), the permanent archive settles at around &lt;strong>500-700 GB in ClickHouse&lt;/strong>, comfortable on the NVMe of the storage node, plus the raw events in MinIO with a 30-day lifecycle. &lt;strong>Without&lt;/strong> the system log hygiene (knob 10), add easily as much again in rubbish nobody queries. The two disk knobs together are the difference between planning storage once a year and fighting a full disk every month.&lt;/p>
&lt;h3 id="maximum-use-checklist">&amp;ldquo;Maximum use&amp;rdquo; checklist&lt;/h3>
&lt;ol>
&lt;li>&lt;strong>Sampling in the Collector&lt;/strong> (tail: 100 % errors + N % normal), before touching anything in Langfuse.&lt;/li>
&lt;li>&lt;strong>Workers scaled by queue length&lt;/strong> via KEDA (knob 1), not fixed.&lt;/li>
&lt;li>&lt;strong>Ingestion separated from UI&lt;/strong> (knob 2) so the dashboard responds during incidents.&lt;/li>
&lt;li>&lt;strong>&lt;code>SKIP_FINAL_FOR_OTEL_PROJECTS&lt;/code>&lt;/strong> enabled (knob 7) because the instrumentation is 100 % OTel.&lt;/li>
&lt;li>&lt;strong>Generous CH batching&lt;/strong> (knob 3) tuned to the throughput, accepting the loss window.&lt;/li>
&lt;li>&lt;strong>Retention + TTL + system log hygiene&lt;/strong> (knobs 9 and 10) configured on day 1, not when the disk screams.&lt;/li>
&lt;li>&lt;strong>ClickHouse with ≥16 GiB and every query filtering by &lt;code>projectId&lt;/code>+time&lt;/strong> (knob 8 in its on-premise version: vertical scaling).&lt;/li>
&lt;li>&lt;strong>Langfuse isolated from the GPUs&lt;/strong> by &lt;code>taints&lt;/code>/&lt;code>nodeSelector&lt;/code>: not a MB of VRAM, no memory bandwidth contention with vLLM.&lt;/li>
&lt;/ol>
&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;p>&lt;strong>&amp;ldquo;Langfuse guarantees me full traceability.&amp;rdquo;&lt;/strong> No: the design is &lt;strong>high-performance best-effort&lt;/strong>, not a ledger. Between the HTTP 207 and the row in ClickHouse there are volatile buffers (Redis without hard persistence, the Worker&amp;rsquo;s in-memory buffer, ClickHouse&amp;rsquo;s server-side &lt;code>async_insert&lt;/code>). There is a &lt;a href="https://github.com/langfuse/langfuse/issues/13468">known bug&lt;/a> where the writer discards rows with no dead-letter queue after exhausting retries. For operational observability, losing 0.01 % of spans is irrelevant. For &lt;strong>ENS/EU AI Act audit evidence&lt;/strong>, where the trace &lt;em>is&lt;/em> the proof, Langfuse should not be the only record; the regulatory audit log needs durability guarantees this pipeline does not promise. A distinction covered in &lt;a href="https://blog.lo0.es/en/posts/technical-controls-ens-iso-42001-eu-ai-act-cross-mapping/">the technical controls for ENS/42001/EU AI Act&lt;/a>.&lt;/p>
&lt;p>&lt;strong>Raising ClickHouse batching &amp;ldquo;to go faster&amp;rdquo; and nothing else.&lt;/strong> Knob 3 improves throughput at the cost of enlarging the loss window and the latency of data appearing in the dashboard. Batches of 50,000 every 10 s perform wonderfully… until the Worker restarts with 50,000 events in the buffer. Tune with awareness of the trade-off, not by blind maximisation.&lt;/p>
&lt;p>&lt;strong>Putting ClickHouse on the same node as vLLM with no limits.&lt;/strong> ClickHouse is voracious with memory bandwidth during merges. Sharing a node with vLLM without &lt;code>resources.limits&lt;/code> or NUMA isolation means an ingestion spike can degrade inference TTFT, exactly the original sin this whole architecture wanted to avoid. Isolate.&lt;/p>
&lt;p>&lt;strong>Forgetting the time filter in your own queries.&lt;/strong> The ClickHouse tables are indexed by &lt;code>projectId&lt;/code> and time. A custom dashboard or an API query without a time filter scans the whole history and takes performance down for everyone. It is not Langfuse &amp;ldquo;being slow&amp;rdquo;: it is a badly written query.&lt;/p>
&lt;p>&lt;strong>Applying lifecycle to the media bucket.&lt;/strong> It breaks the files referenced in traces and blocks future uploads (state is tracked by hash in Postgres). The media bucket is managed &lt;strong>only&lt;/strong> with the Langfuse retention feature, never with blind S3 rules.&lt;/p>
&lt;p>&lt;strong>Treating queue sharding as a routine optimisation.&lt;/strong> It is an advanced knob for a genuinely drowning Redis, irreversible (do not reduce shards) and with &lt;em>per-shard&lt;/em> concurrency semantics that are easy to misread. In the vast majority of on-premise deployments it is not needed; if you enable it &amp;ldquo;just in case&amp;rdquo;, you complicate your life without gaining anything.&lt;/p>
&lt;h2 id="conclusion">Conclusion&lt;/h2>
&lt;p>Langfuse v3 solved the structural problem of LLM observability, that the observer should not suffocate the observed, by moving from a monolith on Postgres to a six-service sorting centre with asynchronous ingestion. That design is what allows a cluster serving thousands of tokens per second to be instrumented end to end without the app ever waiting for a trace to be filed. But the design is a necessary condition, not a sufficient one: it performs if the right levers are tuned. Of the ten knobs, three decide almost everything in a typical on-premise deployment, &lt;strong>scaling Workers by queue length (1), retention + TTL (9), and system log hygiene (10)&lt;/strong>; the rest are refinements that show up when the load bites. And above them all lives knob 0, which is not a Langfuse one: &lt;strong>sampling in the Collector&lt;/strong>, which decides how much reaches the pipeline before any internal setting matters. Maximising Langfuse on the 4×H100 cluster is not about squeezing its peak throughput: it is about putting it off the GPUs, feeding it already-sampled traffic, sizing the Worker by the queue, and configuring retention on day one, so that the tool that came to tell the story does not end up being the chapter about the incident.&lt;/p>
&lt;h2 id="see-also">See also&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/llm-tracing-opentelemetry-genai/">LLM tracing with OpenTelemetry GenAI&lt;/a> — the &lt;code>SDK → Collector → backend&lt;/code> pipeline that feeds Langfuse. There Langfuse is treated as a destination; here it is opened up. The two-layer sampling of that post is the knob 0 that multiplies the ten in this one.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/prompt-versioning-contract-five-word-change/">Prompt versioning with Langfuse and MLflow&lt;/a> — the prompt management layer that lives in Postgres (not in ClickHouse). The &lt;code>prompt_id@version&lt;/code> that post propagates as a span attribute lands in the tracing tables described here.&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> — the Langfuse datasets and evaluators rest on this same backend; the stored traces are the input of continuous eval.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/oss-catalogue-llmops-six-stages-tool-by-tool/">The OSS catalogue for LLMOps in six stages&lt;/a> — the Langfuse entry alongside Phoenix and the rest of the observability ecosystem.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/anatomy-on-premise-llm-inference-stack-seven-layers/">The on-premise LLM inference stack in seven layers&lt;/a> — where Langfuse fits (layer 5, LLM-aware observability) in the whole building and how it is sized on the same 4×H100 cluster.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/llm-inference-autoscaling-kubernetes-hpa-keda/">Autoscaling LLMs on Kubernetes with KEDA&lt;/a> — the concrete mechanism for scaling the Langfuse Workers by &lt;code>langfuse.queue.ingestion.length&lt;/code> (knob 1), the same pattern as for vLLM.&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: ENS, ISO 42001 and the EU AI Act&lt;/a> — why Langfuse is best-effort observability and does not replace the regulatory audit log with durability guarantees.&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 cable between the gateway and this backend: integration routes, &lt;code>traceparent&lt;/code> propagation and the four bounded queues that drop events before they reach the ingestion pipeline described here.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/langfuse-v4-what-goes-into-a-trace/">Langfuse v4: what goes into a trace&lt;/a> — version 4 changed the data model underneath this architecture: there is no trace table any more. It opens the series of eight about operations.&lt;/li>
&lt;/ul>
&lt;h2 id="references">References&lt;/h2>
&lt;ul>
&lt;li>Langfuse, &lt;em>Scaling Langfuse Deployments&lt;/em> (doc oficial de sizing y todos los env vars de este post): &lt;a href="https://langfuse.com/self-hosting/configuration/scaling">https://langfuse.com/self-hosting/configuration/scaling&lt;/a>.&lt;/li>
&lt;li>Langfuse, &lt;em>Self-host Langfuse&lt;/em> y &lt;em>Configuration via Environment Variables&lt;/em>: &lt;a href="https://langfuse.com/self-hosting">https://langfuse.com/self-hosting&lt;/a> · &lt;a href="https://langfuse.com/self-hosting/configuration">https://langfuse.com/self-hosting/configuration&lt;/a>.&lt;/li>
&lt;li>Langfuse, &lt;em>ClickHouse (self-hosted)&lt;/em>: &lt;a href="https://langfuse.com/self-hosting/deployment/infrastructure/clickhouse">https://langfuse.com/self-hosting/deployment/infrastructure/clickhouse&lt;/a>.&lt;/li>
&lt;li>Langfuse, &lt;em>From Zero to Scale: Langfuse&amp;rsquo;s Infrastructure Evolution&lt;/em> (el porqué del rediseño v2→v3): &lt;a href="https://langfuse.com/blog/2024-12-langfuse-v3-infrastructure-evolution">https://langfuse.com/blog/2024-12-langfuse-v3-infrastructure-evolution&lt;/a>.&lt;/li>
&lt;li>Langfuse, &lt;em>Migrate v2 to v3 (self-hosted)&lt;/em>: &lt;a href="https://langfuse.com/self-hosting/upgrade/upgrade-guides/upgrade-v2-to-v3">https://langfuse.com/self-hosting/upgrade/upgrade-guides/upgrade-v2-to-v3&lt;/a>.&lt;/li>
&lt;li>ClickHouse, &lt;em>Langfuse and ClickHouse: A new data stack for modern LLM applications&lt;/em>: &lt;a href="https://clickhouse.com/blog/langfuse-and-clickhouse-a-new-data-stack-for-modern-llm-applications">https://clickhouse.com/blog/langfuse-and-clickhouse-a-new-data-stack-for-modern-llm-applications&lt;/a>.&lt;/li>
&lt;li>Langfuse, issue #13468 — &lt;em>ClickhouseWriter drops rows after max flush attempts with no DLQ&lt;/em> (la ventana de pérdida documentada): &lt;a href="https://github.com/langfuse/langfuse/issues/13468">https://github.com/langfuse/langfuse/issues/13468&lt;/a>.&lt;/li>
&lt;li>ClickHouse, &lt;em>TTL for tables and columns&lt;/em>: &lt;a href="https://clickhouse.com/docs/guides/developer/ttl">https://clickhouse.com/docs/guides/developer/ttl&lt;/a>.&lt;/li>
&lt;li>OpenTelemetry, &lt;em>Semantic Conventions for Generative AI&lt;/em> (&lt;code>gen_ai.*&lt;/code>): &lt;a href="https://opentelemetry.io/docs/specs/semconv/gen-ai/">https://opentelemetry.io/docs/specs/semconv/gen-ai/&lt;/a>.&lt;/li>
&lt;/ul></description></item></channel></rss>