<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom"><channel><title>Observability on lo0 — Tech Blog</title><link>https://blog.lo0.es/en/tags/observability/</link><description>Recent content in Observability 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/observability/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>The gateway does not live alone: the JWT that does not validate the audience, the user who never reaches the trace, and the seams you have to write by hand</title><link>https://blog.lo0.es/en/posts/gateway-jwt-audience-trace-identity-seams/</link><pubDate>Sat, 12 Sep 2026 06:30:00 +0200</pubDate><guid>https://blog.lo0.es/en/posts/gateway-jwt-audience-trace-identity-seams/</guid><description>&lt;blockquote>
&lt;p>Eighth article in the operational track of the control layer. The previous seven deal with the gateway from the inside: &lt;a href="https://blog.lo0.es/en/posts/litellm-langfuse-operational-pair/">the pair with Langfuse&lt;/a>, &lt;a href="https://blog.lo0.es/en/posts/litellm-day-2-high-availability/">day 2&lt;/a>, &lt;a href="https://blog.lo0.es/en/posts/litellm-virtual-keys-budgets-limits/">virtual keys&lt;/a>, &lt;a href="https://blog.lo0.es/en/posts/humans-agents-same-gateway-priority-traffic-separation/">humans and agents&lt;/a>, &lt;a href="https://blog.lo0.es/en/posts/prefix-routing-what-litellm-does-not-do/">prefix routing&lt;/a>, &lt;a href="https://blog.lo0.es/en/posts/litellm-mcp-gateway-second-front-door/">the MCP gateway&lt;/a> and &lt;a href="https://blog.lo0.es/en/posts/sizing-agents-decode-bottleneck/">sizing for agents&lt;/a>. This one deals with what surrounds it. Verified against LiteLLM 1.102.0 and Langfuse 4.35.0, both with a commit of 11 September 2026.&lt;/p>
&lt;/blockquote>
&lt;h2 id="tldr">TL;DR&lt;/h2>
&lt;p>The gateway has five neighbours: a database, a cache, an identity provider, a trace backend and a collector. Each neighbourhood has its own physics, and these are the seven things it decides.&lt;/p>
&lt;p>&lt;strong>The proxy starts without Postgres, but then only the master key works.&lt;/strong> The &lt;code>DATABASE_URL&lt;/code> variable is optional (&lt;code>proxy_server.py:1163&lt;/code>); without it, any key other than the master one returns a 400 for a missing connection (&lt;code>user_api_key_auth.py:1886&lt;/code>). With a database configured but down, the default behaviour is to return 503, unless &lt;code>general_settings.allow_requests_on_db_unavailable&lt;/code> is switched on.&lt;/p>
&lt;p>&lt;strong>Redis is never mandatory, and that is precisely the trap.&lt;/strong> There is a circuit breaker with a threshold of five failures and a recovery of sixty seconds (&lt;code>constants.py:456&lt;/code>). When it opens, the proxy fails no request: it degrades to local memory. What is lost silently is that rate limits stop being global and become per pod.&lt;/p>
&lt;p>&lt;strong>No observability backend can bring a request down.&lt;/strong> All callbacks are launched with &lt;code>asyncio.create_task&lt;/code> and their exceptions are caught and logged (&lt;code>litellm_logging.py:3235&lt;/code>). If Langfuse does not respond, events are discarded as the queue fills up. There is no added latency, and there is no reliable record either: the pair&amp;rsquo;s observability is a statistical figure, not an audit record.&lt;/p>
&lt;p>&lt;strong>JWT authentication validates neither the audience nor the issuer unless two variables are defined.&lt;/strong> In &lt;code>_build_decode_kwargs&lt;/code> (&lt;code>handle_jwt.py:1003&lt;/code>) the audience comes from &lt;code>JWT_AUDIENCE&lt;/code> and the issuer from &lt;code>JWT_ISSUER&lt;/code>; if they are empty, the code sets &lt;code>verify_aud=False&lt;/code> and &lt;code>verify_iss=False&lt;/code> and emits a warning once only. Any valid token signed by that provider, issued for another service, is accepted.&lt;/p>
&lt;p>&lt;strong>Role-based access control ships disabled.&lt;/strong> &lt;code>enforce_rbac&lt;/code> has a default value of &lt;code>False&lt;/code> (&lt;code>_types.py:4848&lt;/code> onwards). With that value, a token whose role resolves to nothing does not produce a 403: it carries on through the flow. Real authorisation, then, comes from team membership, if there is a team claim configured at all.&lt;/p>
&lt;p>&lt;strong>The user identifier that reaches Langfuse is not the one from Keycloak.&lt;/strong> The &lt;code>user_id&lt;/code> field of the trace is filled in with &lt;code>user_api_key_end_user_id&lt;/code> (&lt;code>integrations/langfuse/langfuse.py:604&lt;/code>), that is, with the &lt;code>user&lt;/code> field of the OpenAI body, which the client sets. The subject of the token and the owner of the key do not travel. Anyone who wants per-person traceability needs to configure &lt;code>end_user_id_jwt_field&lt;/code> or make the client send that field.&lt;/p>
&lt;p>&lt;strong>Langfuse provisions nothing from a claim.&lt;/strong> There is no variable that maps an OIDC claim to an organisation or to a project. The only thing available is static assignment at sign-up with &lt;code>LANGFUSE_DEFAULT_ORG_ID&lt;/code> and &lt;code>LANGFUSE_DEFAULT_PROJECT_ID&lt;/code>, the same for everyone. On top of that, per-project access control sits behind the Enterprise licence in the self-hosted edition, as do audit logs and per-project retention.&lt;/p>
&lt;h2 id="you-are-here-between-the-four-pieces">You are here: between the four pieces&lt;/h2>
&lt;p>The &lt;a href="https://blog.lo0.es/en/posts/litellm-langfuse-operational-pair/">LiteLLM and Langfuse pair&lt;/a> walked through the seam between the gateway and the trace backend: integration routes, cost that arrives as zero, correlation and queues with discarding. This article widens the focus to the other three neighbourhoods, with identity at the centre, because that is the one that orders everything else and the one an auditor asks about.&lt;/p>
&lt;p>The identity piece itself, that is, Keycloak, is dealt with in &lt;a href="https://blog.lo0.es/en/posts/keycloak-identity-plane-ai-platform/">the next article&lt;/a>. Here we deal with the gateway side.&lt;/p>
&lt;h2 id="the-analogy-the-building-site-with-five-trades">The analogy: the building site with five trades&lt;/h2>
&lt;p>On a small building site, the problem is rarely inside each trade&amp;rsquo;s work, it is at the joins: where the plumber leaves the pipe sleeve and the bricklayer closes up the partition wall, where the electrician wants to run through the same gap. Each trade does its part well and the join ends up badly resolved because it belongs to nobody.&lt;/p>
&lt;p>An inference platform has five trades. The gateway knows about tokens, the identity provider knows about people, the trace backend knows about events, the database knows about spend and the cache knows about counters. Each one has good documentation. The joins have nobody&amp;rsquo;s documentation, and that is where the user&amp;rsquo;s identity gets lost between the token and the trace.&lt;/p>
&lt;p>There is one more detail of the analogy that is useful. On a building site, the part that causes the most problems is not the one that looks badly finished, it is the one that gets covered up. The same happens here: the five seams in this article fail silently.&lt;/p>
&lt;h2 id="seam-1-what-brings-things-down-and-what-degrades-them">Seam 1. What brings things down and what degrades them&lt;/h2>
&lt;p>It pays to have written down what happens when each neighbour falls over, because intuition fails in almost every case.&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Neighbour&lt;/th>
&lt;th>Does it start without it?&lt;/th>
&lt;th>If it falls over&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Postgres&lt;/td>
&lt;td>Yes, master key only&lt;/td>
&lt;td>503 by default; degraded with &lt;code>allow_requests_on_db_unavailable&lt;/code>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Redis&lt;/td>
&lt;td>Yes&lt;/td>
&lt;td>Circuit breaker after 5 failures; limits become per pod&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Langfuse&lt;/td>
&lt;td>Yes&lt;/td>
&lt;td>Events discarded as the queue fills; no impact on latency&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>OTel collector&lt;/td>
&lt;td>Yes&lt;/td>
&lt;td>Same as the previous one&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Identity provider&lt;/td>
&lt;td>Yes&lt;/td>
&lt;td>Access to the interface and the JWT flow goes down; virtual keys carry on&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;h3 id="postgres">Postgres&lt;/h3>
&lt;p>Without &lt;code>DATABASE_URL&lt;/code>, &lt;code>_setup_prisma_client&lt;/code> returns &lt;code>None&lt;/code> without error (&lt;code>proxy_server.py:10375&lt;/code>). The proxy serves &lt;code>/chat/completions&lt;/code> with the master key. Any other key gets a 400. There is no global budget either, and start-up itself warns about it: Redis does not replace the database (&lt;code>proxy_server.py:9240&lt;/code>).&lt;/p>
&lt;p>With a database configured and down, the exception handler re-raises unless the degradation flag is active (&lt;code>db/exception_handler.py:56&lt;/code>). There is also a &lt;code>DISABLE_PRISMA_HEALTH_CHECK_ON_STARTUP&lt;/code> and a watcher that reconnects. The detail that matters for diagnosis: &lt;strong>a transient database failure returns 503, not 401&lt;/strong>. If during an incident you see 401s, the problem is credentials; if you see 503s, it is the database or admission.&lt;/p>
&lt;h3 id="redis">Redis&lt;/h3>
&lt;p>The circuit breaker is enabled by default, with a threshold of five failures, a recovery of sixty seconds and a minimum duration of five (&lt;code>constants.py:456&lt;/code>, logic in &lt;code>caching/redis_cache.py:153&lt;/code>). The socket has a timeout of 0.1 seconds (&lt;code>constants.py:453&lt;/code>). With the circuit open, &lt;code>DualCache&lt;/code> serves from memory without propagating an exception (&lt;code>dual_cache.py:215&lt;/code>).&lt;/p>
&lt;p>The consequence has to be measured, not assumed: with four workers and three replicas, a limit of one hundred requests per minute becomes one thousand two hundred. It is a failure mode that generates no errors and that can last for days without anyone noticing, until the bill arrives or the engine saturates.&lt;/p>
&lt;p>The right alert is not about Redis, it is about &lt;code>litellm_service_latency&lt;/code> labelled by service (&lt;code>integrations/prometheus_services.py:119&lt;/code>), which exposes Redis and database latency separately.&lt;/p>
&lt;h3 id="the-callbacks">The callbacks&lt;/h3>
&lt;p>Everything behind, that is, Langfuse and the collector, is launched outside the response path and its failures are caught. The classic integration uses the v2 SDK with its own background thread and a flush interval of one second, adjustable with &lt;code>LANGFUSE_FLUSH_INTERVAL&lt;/code> (&lt;code>langfuse.py:198&lt;/code>). There is a cap of fifty instantiated Langfuse clients (&lt;code>constants.py:554&lt;/code>), because each client is a thread.&lt;/p>
&lt;p>The OTLP integration uses the batch processor of the OpenTelemetry SDK (&lt;code>opentelemetry.py:3060&lt;/code>) with its default values. In both cases, saturation means discarding, and discarding generates no error towards the client.&lt;/p>
&lt;h2 id="seam-2-identity-the-three-doors-and-what-each-one-validates">Seam 2. Identity: the three doors and what each one validates&lt;/h2>
&lt;p>Everything goes through a single authentication constructor (&lt;code>user_api_key_auth.py:1245&lt;/code>), in this order:&lt;/p>
&lt;ol>
&lt;li>Custom authentication from the enterprise edition.&lt;/li>
&lt;li>Public routes.&lt;/li>
&lt;li>Opaque OAuth2, if enabled, with a licence check.&lt;/li>
&lt;li>JWT, if &lt;code>general_settings.enable_jwt_auth&lt;/code> is active and the token has three parts.&lt;/li>
&lt;li>Master key, with constant-time comparison.&lt;/li>
&lt;li>Virtual key, with SHA-256 hash and lookup in the verification table.&lt;/li>
&lt;/ol>
&lt;p>A JWT works for &lt;code>/chat/completions&lt;/code>, not just for administration: the routes allowed by default for a team include &lt;code>openai_routes&lt;/code> (&lt;code>_types.py:4886&lt;/code>), which contains the chat endpoint. Administration, on the other hand, is only reachable by a token with an administrator role.&lt;/p>
&lt;h3 id="the-first-thing-to-know-it-is-paid-for">The first thing to know: it is paid for&lt;/h3>
&lt;p>The JWT branch carries an explicit licence check, with the literal message that JWT authentication is an enterprise-only feature (&lt;code>user_api_key_auth.py:1416&lt;/code>). SSO for the administration interface is free up to five billable users and requires a licence above that (&lt;code>ui_sso.py:976&lt;/code>).&lt;/p>
&lt;p>This conditions the architecture of any sovereign deployment that does not want to pay for a licence: the Keycloak integration is left for the administration interface and for provisioning, while inference traffic authenticates with virtual keys. Virtual keys are not worse, but they are a different thing, and it has to be said in the risk analysis: they are credentials decoupled from the identity provider, so removing a person from Keycloak does not invalidate their key.&lt;/p>
&lt;h3 id="the-litellm_jwtauth-fields-that-decide-security">The &lt;code>litellm_jwtauth&lt;/code> fields that decide security&lt;/h3>
&lt;p>From the &lt;code>LiteLLM_JWTAuth&lt;/code> class (&lt;code>_types.py:4848&lt;/code> onwards), these are the ones that matter, with their default values:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Field&lt;/th>
&lt;th>Default&lt;/th>
&lt;th>What it does&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>&lt;code>enforce_rbac&lt;/code>&lt;/td>
&lt;td>&lt;code>False&lt;/code>&lt;/td>
&lt;td>If false, an unresolved role does not produce a 403&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>enforce_scope_based_access&lt;/code>&lt;/td>
&lt;td>&lt;code>False&lt;/code>&lt;/td>
&lt;td>Without this, &lt;code>scope_mappings&lt;/code> is not applied&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>enforce_team_based_model_access&lt;/code>&lt;/td>
&lt;td>&lt;code>False&lt;/code>&lt;/td>
&lt;td>Model access by team&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>team_id_jwt_field&lt;/code>&lt;/td>
&lt;td>&lt;code>None&lt;/code>&lt;/td>
&lt;td>Claim the team comes from&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>team_id_upsert&lt;/code>&lt;/td>
&lt;td>&lt;code>False&lt;/code>&lt;/td>
&lt;td>If false, a team that does not exist gives a 404&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>user_id_upsert&lt;/code>&lt;/td>
&lt;td>&lt;code>False&lt;/code>&lt;/td>
&lt;td>Same with the user&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>public_key_ttl&lt;/code>&lt;/td>
&lt;td>&lt;code>600&lt;/code>&lt;/td>
&lt;td>Cache of the public keys&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>public_key_stale_ttl&lt;/code>&lt;/td>
&lt;td>&lt;code>3600&lt;/code>&lt;/td>
&lt;td>Extra margin if the provider is down&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>admin_jwt_scope&lt;/code>&lt;/td>
&lt;td>&lt;code>litellm_proxy_admin&lt;/code>&lt;/td>
&lt;td>Scope that grants administration&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>user_allowed_email_domain&lt;/code>&lt;/td>
&lt;td>&lt;code>None&lt;/code>&lt;/td>
&lt;td>Restriction by email domain&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>The accepted algorithms are RS256/384/512, PS256/384/512, ES256/384/512 and EdDSA (&lt;code>handle_jwt.py:163&lt;/code>). There is no symmetric one, which eliminates the algorithm confusion class of attack. It is a good design decision and it deserves recognition.&lt;/p>
&lt;p>Key download supports several comma-separated URLs in &lt;code>JWT_PUBLIC_KEY_URL&lt;/code>, resolves discovery if the URL points to the well-known OpenID configuration, retries three times with increasing waits and memoises the failure for thirty seconds (&lt;code>handle_jwt.py:680&lt;/code>, &lt;code>:751&lt;/code>, &lt;code>:88&lt;/code>). If the provider is down, it serves the stale copy up to the sum of the two TTLs, that is, up to seventy minutes. For availability that is fine; for revocation it is not, and it has to be written down.&lt;/p>
&lt;p>One detail of key rotation: with more than one key in the set, an exact match of the key identifier is required, but &lt;strong>with a single key and no identifier it is accepted without checking&lt;/strong> (&lt;code>handle_jwt.py:913&lt;/code>).&lt;/p>
&lt;h3 id="the-validation-that-does-not-happen">The validation that does not happen&lt;/h3>
&lt;p>This is the part to fix on day one. In &lt;code>_build_decode_kwargs&lt;/code> (&lt;code>handle_jwt.py:1003&lt;/code>), the audience is taken from the &lt;code>JWT_AUDIENCE&lt;/code> environment variable and the issuer from &lt;code>JWT_ISSUER&lt;/code>. If they are not defined, the code sets &lt;code>verify_aud=False&lt;/code> and &lt;code>verify_iss=False&lt;/code>, and emits a warning once only.&lt;/p>
&lt;p>What that means in a real deployment with Keycloak: a token issued for the Grafana client, or for the Backstage one, signed by the same realm, is accepted by the gateway as a valid credential. Expiry is always validated, with zero margin. So is the signature. The audience is not.&lt;/p>
&lt;p>There is a second route, &lt;code>issuers&lt;/code>, with per-issuer configuration, which does validate issuer and audience unless explicitly disabled (&lt;code>handle_jwt.py:1186&lt;/code>). That is the one to use when there is more than one provider.&lt;/p>
&lt;p>The minimum fix is two lines of environment:&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">JWT_AUDIENCE&lt;/span>&lt;span class="o">=&lt;/span>litellm-gateway
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="nv">JWT_ISSUER&lt;/span>&lt;span class="o">=&lt;/span>https://sso.ejemplo.es/realms/plataforma
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>And on the Keycloak side, an audience mapper in a dedicated client scope that injects that audience. The detail is &lt;a href="https://blog.lo0.es/en/posts/keycloak-identity-plane-ai-platform/">in the Keycloak article&lt;/a>, because the standard that would resolve this cleanly, resource indicators, is not supported.&lt;/p>
&lt;h3 id="from-claim-to-permission">From claim to permission&lt;/h3>
&lt;p>The administrator role comes from &lt;code>admin_jwt_scope&lt;/code> appearing in the &lt;code>scope&lt;/code> claim, and it returns a result without going through a team (&lt;code>handle_jwt.py:309&lt;/code>, &lt;code>:1373&lt;/code>). The rest is resolved by team.&lt;/p>
&lt;p>If the team in the claim does not exist in the database, with &lt;code>team_id_upsert&lt;/code> false a 404 is returned with the message that the team has to be created (&lt;code>auth_checks.py:3035&lt;/code>). With the flag active, it is created by invoking team creation with a synthetic administrator identity (&lt;code>auth_checks.py:2874&lt;/code>). That is the only automatic provisioning route that exists from Keycloak towards the gateway, and it works well. It pays to know that it creates teams with no budget and no limits, so a default value is needed in the configuration.&lt;/p>
&lt;h3 id="the-vulnerability-worth-knowing-about">The vulnerability worth knowing about&lt;/h3>
&lt;p>&lt;code>CVE-2026-35030&lt;/code>, scoring 9.1 on CVSS 3.1 and 9.4 on CVSS 4.0, advisory &lt;code>GHSA-jjhc-v7c2-5hh6&lt;/code>. With JWT authentication active, the cache of the OIDC user info endpoint used the first twenty characters of the token as the key, so that two tokens with the same prefix were confused with each other. Fixed in 1.83.0.&lt;/p>
&lt;p>In 1.102.0 the code uses the full SHA-256 hash (&lt;code>handle_jwt.py:962&lt;/code>). A search for similar patterns across the tree finds no authentication cache indexed by token prefix: the truncations that remain are masked for logging. And it is worth recalling what has already been published &lt;a href="https://blog.lo0.es/en/posts/litellm-mcp-gateway-second-front-door/">about the MCP gateway&lt;/a>: below 1.83.14 there is unauthenticated remote execution or SQL injection.&lt;/p>
&lt;h2 id="seam-3-identity-does-not-reach-the-trace">Seam 3. Identity does not reach the trace&lt;/h2>
&lt;p>Here lies the most expensive design flaw in the arrangement, because it is discovered at the first audit.&lt;/p>
&lt;p>The &lt;code>user_id&lt;/code> field of a Langfuse trace is filled in with &lt;code>user_api_key_end_user_id&lt;/code> (&lt;code>integrations/langfuse/langfuse.py:604&lt;/code> and &lt;code>:724&lt;/code>), which comes from the &lt;code>user&lt;/code> field of the OpenAI request body or from &lt;code>end_user_id_jwt_field&lt;/code> (&lt;code>litellm_pre_call_utils.py:1584&lt;/code>). It is not the subject of the token nor the owner of the virtual key.&lt;/p>
&lt;p>The knock-on consequences:&lt;/p>
&lt;ul>
&lt;li>If the client does not send &lt;code>user&lt;/code>, the trace has no user.&lt;/li>
&lt;li>If the client does send &lt;code>user&lt;/code>, the trace has whatever value the client cares to put there, unverified.&lt;/li>
&lt;li>An agent calling with a service key leaves traces with no person behind them.&lt;/li>
&lt;/ul>
&lt;p>There are three ways to fix it, in order of robustness:&lt;/p>
&lt;ol>
&lt;li>&lt;strong>Configure &lt;code>end_user_id_jwt_field&lt;/code>&lt;/strong> so that the end user comes from a token claim and not from the body. It is the only option in which the value is not controlled by the client, and it requires JWT authentication, that is, a licence.&lt;/li>
&lt;li>&lt;strong>Force the &lt;code>trace_user_id&lt;/code> metadata field&lt;/strong>, which overwrites the field (&lt;code>langfuse.py:726&lt;/code>; on the OTLP route, &lt;code>langfuse_otel.py:96&lt;/code>). It can be set per team.&lt;/li>
&lt;li>&lt;strong>Contractually require the client to send &lt;code>user&lt;/code>&lt;/strong> and validate it in a guardrail. It works, but it is a client declaration, not a proof.&lt;/li>
&lt;/ol>
&lt;p>Something similar happens towards the engine. The caller&amp;rsquo;s identity does not travel to vLLM unless &lt;code>litellm.add_user_information_to_llm_headers&lt;/code> is switched on, whose default value is &lt;code>None&lt;/code> (&lt;code>litellm/__init__.py:223&lt;/code>). With it, &lt;code>x-litellm-*&lt;/code> headers are injected with user identifier, team identifier and key hash (&lt;code>litellm_pre_call_utils.py:1356&lt;/code>). Forwarding client headers requires &lt;code>general_settings.forward_client_headers_to_llm_api&lt;/code>, and the &lt;code>authorization&lt;/code> header is always filtered out, which is correct.&lt;/p>
&lt;p>Towards MCP servers, by default the server&amp;rsquo;s own credential is used, not the user&amp;rsquo;s token. Only two modes forward the caller&amp;rsquo;s token, and doing so has a name in the MCP specification: token passthrough, which is explicitly forbidden. It comes up again &lt;a href="https://blog.lo0.es/en/posts/keycloak-identity-plane-ai-platform/">in the Keycloak article&lt;/a>.&lt;/p>
&lt;h2 id="seam-4-langfuse-as-a-neighbour">Seam 4. Langfuse as a neighbour&lt;/h2>
&lt;h3 id="what-can-be-automated-and-what-cannot">What can be automated and what cannot&lt;/h3>
&lt;p>The exact list of variables for SSO in Langfuse 4.x, taken from &lt;code>web/src/env.mjs&lt;/code>:&lt;/p>
&lt;p>With the Keycloak provider: &lt;code>AUTH_KEYCLOAK_CLIENT_ID&lt;/code>, &lt;code>AUTH_KEYCLOAK_CLIENT_SECRET&lt;/code>, &lt;code>AUTH_KEYCLOAK_ISSUER&lt;/code>, &lt;code>AUTH_KEYCLOAK_ALLOW_ACCOUNT_LINKING&lt;/code>, &lt;code>AUTH_KEYCLOAK_CLIENT_AUTH_METHOD&lt;/code>, &lt;code>AUTH_KEYCLOAK_CHECKS&lt;/code>, &lt;code>AUTH_KEYCLOAK_ID_TOKEN_SIGNED_RESPONSE_ALG&lt;/code>, &lt;code>AUTH_KEYCLOAK_SCOPE&lt;/code>, &lt;code>AUTH_KEYCLOAK_ID_TOKEN&lt;/code>, &lt;code>AUTH_KEYCLOAK_NAME&lt;/code>.&lt;/p>
&lt;p>With generic OIDC: the same set with the &lt;code>AUTH_CUSTOM_&lt;/code> prefix, plus &lt;code>AUTH_CUSTOM_FETCH_USERINFO&lt;/code> and claim mapping with &lt;code>LANGFUSE_CUSTOM_SSO_SUB_CLAIM&lt;/code>, &lt;code>LANGFUSE_CUSTOM_SSO_EMAIL_CLAIM&lt;/code>, &lt;code>LANGFUSE_CUSTOM_SSO_NAME_CLAIM&lt;/code> and &lt;code>LANGFUSE_CUSTOM_SSO_IMAGE_CLAIM&lt;/code>.&lt;/p>
&lt;p>Global ones: &lt;code>AUTH_DISABLE_USERNAME_PASSWORD&lt;/code>, &lt;code>AUTH_DISABLE_SIGNUP&lt;/code>, &lt;code>AUTH_DOMAINS_WITH_SSO_ENFORCEMENT&lt;/code>, &lt;code>AUTH_SESSION_MAX_AGE&lt;/code>.&lt;/p>
&lt;p>And now what is missing. &lt;strong>There is no variable that maps a claim to an organisation or a project.&lt;/strong> The only thing available is static assignment at sign-up with &lt;code>LANGFUSE_DEFAULT_ORG_ID&lt;/code>, &lt;code>LANGFUSE_DEFAULT_ORG_ROLE&lt;/code> with a default value of &lt;code>VIEWER&lt;/code>, &lt;code>LANGFUSE_DEFAULT_PROJECT_ID&lt;/code> and &lt;code>LANGFUSE_DEFAULT_PROJECT_ROLE&lt;/code> (&lt;code>features/auth/lib/createProjectMembershipsOnSignup.ts:61&lt;/code>). The documentation itself confirms it: enterprise SSO does not provision roles automatically at sign-up.&lt;/p>
&lt;p>On top of that, in the per-edition permissions table (&lt;code>features/entitlements/constants/entitlements.ts:139&lt;/code>), the open and self-hosted professional editions &lt;strong>do not include&lt;/strong> per-project access control or the administration API. They belong to the enterprise edition, as do audit logs and per-project retention. In the open edition, every user inherits the role of their organisation.&lt;/p>
&lt;h3 id="the-declarative-bootstrap-which-does-exist">The declarative bootstrap, which does exist&lt;/h3>
&lt;p>For on-premise without a licence, the workable route is bootstrapping through variables (&lt;code>web/src/initialize.ts&lt;/code>), which is idempotent: &lt;code>LANGFUSE_INIT_ORG_ID&lt;/code> (mandatory, if missing the rest is ignored with a warning), &lt;code>LANGFUSE_INIT_ORG_NAME&lt;/code>, &lt;code>LANGFUSE_INIT_PROJECT_ID&lt;/code>, &lt;code>LANGFUSE_INIT_PROJECT_NAME&lt;/code>, &lt;code>LANGFUSE_INIT_PROJECT_RETENTION&lt;/code>, &lt;code>LANGFUSE_INIT_PROJECT_PUBLIC_KEY&lt;/code>, &lt;code>LANGFUSE_INIT_PROJECT_SECRET_KEY&lt;/code>, &lt;code>LANGFUSE_INIT_USER_EMAIL&lt;/code>, &lt;code>LANGFUSE_INIT_USER_NAME&lt;/code>, &lt;code>LANGFUSE_INIT_USER_PASSWORD&lt;/code>.&lt;/p>
&lt;p>With that, one project per tenant is created from GitOps with one initialisation container per tenant and the keys in Kubernetes secrets.&lt;/p>
&lt;h3 id="langfuse-credentials-per-team">Langfuse credentials per team&lt;/h3>
&lt;p>This part is well resolved on the gateway side and few people use it. &lt;code>TeamCallbackMetadata.callback_vars&lt;/code> (&lt;code>_types.py:2152&lt;/code>) accepts &lt;code>langfuse_public_key&lt;/code>, &lt;code>langfuse_secret_key&lt;/code>, &lt;code>langfuse_host&lt;/code> and &lt;code>langfuse_environment&lt;/code>, with an allow list in &lt;code>initialize_dynamic_callback_params.py:67&lt;/code>. The gateway caches one logger per credential set (&lt;code>langfuse_handler.py:24&lt;/code>) and, on the OTLP route, builds a full exporter per key (&lt;code>langfuse_otel.py:400&lt;/code>).&lt;/p>
&lt;p>That is to say: &lt;strong>one Langfuse project per LiteLLM team is possible today&lt;/strong>, without a licence, and it is the correct way to isolate traces between tenants. What does not exist is automatic project creation, nor the correspondence between the team and the project. That is glue you have to write.&lt;/p>
&lt;h3 id="the-ingestion-route">The ingestion route&lt;/h3>
&lt;p>Langfuse accepts &lt;strong>only OTLP over HTTP&lt;/strong>, in &lt;code>http/protobuf&lt;/code> or &lt;code>http/json&lt;/code>. gRPC is not supported. The base route is &lt;code>/api/public/otel&lt;/code> and the signal lives at &lt;code>/api/public/otel/v1/traces&lt;/code>, verified in the repository. Authentication is Basic with the project&amp;rsquo;s key pair, plus the &lt;code>x-langfuse-ingestion-version: 4&lt;/code> header for the version 4 route.&lt;/p>
&lt;p>The gateway builds it exactly like that (&lt;code>langfuse_otel.py:328&lt;/code>, &lt;code>:347&lt;/code>, &lt;code>:359&lt;/code>) and then normalises the endpoint by adding the signal suffix (&lt;code>opentelemetry.py:3237&lt;/code>). The result matches the real route, so the integration works without touching anything. The detail matters when a collector is put in the middle and somebody copies the endpoint by hand.&lt;/p>
&lt;p>And the calendar warning that already appeared &lt;a href="https://blog.lo0.es/en/posts/litellm-langfuse-operational-pair/">in the operational pair&lt;/a> still stands: the classic callback is tied to the v2 SDK and the date marked is 16 November 2026. With a nuance worth reading before scheduling an emergency migration, which is detailed &lt;a href="https://blog.lo0.es/en/posts/langfuse-v4-what-goes-into-a-trace/">in the data model article&lt;/a>: that date applies to Langfuse Cloud, and in the code of the self-hosted version the old route is not switched off, it changes behaviour according to the write mode.&lt;/p>
&lt;h3 id="when-to-put-a-collector-in">When to put a collector in&lt;/h3>
&lt;p>Direct if Langfuse is the only trace destination. Via a collector if there is more than one consumer, or if any of these three things is needed that the proxy&amp;rsquo;s batch processor does not provide:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Retries with increasing waits and an on-disk queue&lt;/strong>, with the &lt;code>sending_queue&lt;/code> and &lt;code>file_storage&lt;/code> processors. With that, a Langfuse maintenance window stops losing traces.&lt;/li>
&lt;li>&lt;strong>Tail sampling&lt;/strong>, which lets you keep 100 % of errors and 1 % of the rest. The proxy cannot sample by outcome, because at the point it decides it does not yet know how things ended.&lt;/li>
&lt;li>&lt;strong>Attribute redaction&lt;/strong> before they leave the namespace. It is the only layer where you can delete the MCP tool arguments which, as has already been documented, are written in the clear, bypassing the message redaction switch.&lt;/li>
&lt;/ul>
&lt;h2 id="seam-5-postgres-redis-and-the-memory-policy">Seam 5. Postgres, Redis and the memory policy&lt;/h2>
&lt;p>&lt;strong>Separate Postgres, always.&lt;/strong> Each piece applies its own Prisma migrations over the public schema of the database it is given. Two Prisma clients migrating the same schema collide over table names and over migration history. On top of that, Langfuse 4.x requires Postgres 15 as a minimum, recommends 16 and wants UTC by default. The load profile is also the opposite: the gateway writes spend records at high frequency, while Langfuse does light transactional work because trace data goes to ClickHouse.&lt;/p>
&lt;p>&lt;strong>Separate Redis, and the argument is not the one it looks like.&lt;/strong> There is no key collision by design: the gateway uses hash tags of the form &lt;code>{api_key:...}:requests&lt;/code> and an optional namespace, and Langfuse uses the BullMQ prefix plus its own prefix for its key cache. The decisive argument is another one: &lt;strong>Langfuse requires &lt;code>maxmemory-policy=noeviction&lt;/code>&lt;/strong> because its queues are data, not cache, while the gateway assumes a disposable cache. Sharing an instance means that a spike of rate limit keys can fill the memory and cause write errors in the ingestion queues instead of a harmless eviction.&lt;/p>
&lt;p>If it is shared anyway, two minimum measures: &lt;code>REDIS_KEY_PREFIX&lt;/code> in Langfuse, &lt;code>namespace&lt;/code> in the gateway, and different logical databases.&lt;/p>
&lt;p>And a reminder about surface area: Langfuse 4.x &lt;strong>does not start without ClickHouse or without an S3-compatible bucket&lt;/strong>. &lt;code>CLICKHOUSE_URL&lt;/code> and &lt;code>LANGFUSE_S3_EVENT_UPLOAD_BUCKET&lt;/code> are mandatory and have no default value (&lt;code>packages/shared/src/env.ts:120&lt;/code> and &lt;code>:271&lt;/code>). Its failure surface is far larger than the gateway&amp;rsquo;s, and that changes where to put the high availability effort.&lt;/p>
&lt;h2 id="ports-for-the-networkpolicy">Ports, for the NetworkPolicy&lt;/h2>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Piece&lt;/th>
&lt;th>Port&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>LiteLLM (API, interface and metrics)&lt;/td>
&lt;td>4000&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Langfuse web&lt;/td>
&lt;td>3000&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Langfuse worker&lt;/td>
&lt;td>3030&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>ClickHouse&lt;/td>
&lt;td>8123 HTTP, 9000 native&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>S3-compatible storage&lt;/td>
&lt;td>9000&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Redis&lt;/td>
&lt;td>6379&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Postgres&lt;/td>
&lt;td>5432&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>OTel collector&lt;/td>
&lt;td>4317 gRPC, 4318 HTTP&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>The pairs to allow under a default-deny policy, following the method from &lt;a href="https://blog.lo0.es/en/posts/hardening-secrets-sovereign-llm-stack-defence-in-depth/">hardening the stack&lt;/a>:&lt;/p>
&lt;pre tabindex="0">&lt;code>litellm → postgres:5432, redis:6379
litellm → collector:4318 (or langfuse-web:3000 if going direct)
litellm → vllm:8000
litellm → keycloak:8443 (interface and JWT flow only)
collector → langfuse-web:3000
langfuse-web → postgres:5432, redis:6379, clickhouse:8123, s3:9000
langfuse-worker→ postgres:5432, redis:6379, clickhouse:8123, s3:9000
langfuse-web → keycloak:8443
ingress → litellm:4000, langfuse-web:3000
prometheus → litellm:4000
&lt;/code>&lt;/pre>&lt;p>The Langfuse worker needs no inbound traffic beyond the health probe.&lt;/p>
&lt;h2 id="the-glue-you-have-to-write">The glue you have to write&lt;/h2>
&lt;p>Put into a table, the real state of the tenant chain:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Segment&lt;/th>
&lt;th>Automatic?&lt;/th>
&lt;th>How&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Keycloak → LiteLLM team&lt;/td>
&lt;td>Yes&lt;/td>
&lt;td>&lt;code>team_ids_jwt_field&lt;/code> plus &lt;code>team_id_upsert: true&lt;/code>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Keycloak → LiteLLM user&lt;/td>
&lt;td>Yes&lt;/td>
&lt;td>&lt;code>user_id_jwt_field&lt;/code> plus &lt;code>user_id_upsert: true&lt;/code>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Keycloak → Langfuse organisation&lt;/td>
&lt;td>&lt;strong>No&lt;/strong>&lt;/td>
&lt;td>Static assignment at sign-up only&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Keycloak → Langfuse project&lt;/td>
&lt;td>&lt;strong>No&lt;/strong>&lt;/td>
&lt;td>Same&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>LiteLLM team → Langfuse project&lt;/td>
&lt;td>&lt;strong>No&lt;/strong>&lt;/td>
&lt;td>&lt;code>callback_vars&lt;/code> by hand, or an operator of your own&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Keycloak user → trace &lt;code>user_id&lt;/code>&lt;/td>
&lt;td>&lt;strong>No&lt;/strong>&lt;/td>
&lt;td>&lt;code>end_user_id_jwt_field&lt;/code>, or a contract with the client&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>The minimum viable architecture without a licence, then, is this:&lt;/p>
&lt;ol>
&lt;li>One Langfuse project per tenant, created with the initialisation variables from GitOps.&lt;/li>
&lt;li>That project&amp;rsquo;s keys in a Kubernetes secret.&lt;/li>
&lt;li>A periodic job of your own that reads the teams through the gateway&amp;rsquo;s administration API and writes &lt;code>callback_vars&lt;/code> with the corresponding keys.&lt;/li>
&lt;li>People entering Langfuse through Keycloak, but assigned by hand to their organisation.&lt;/li>
&lt;li>The end user identifier resolved by contract with the clients, and validated.&lt;/li>
&lt;/ol>
&lt;p>That is about one hundred and fifty lines of your own code. It pays to budget for them from the start, because the gap does not close by itself.&lt;/p>
&lt;h2 id="reference-configuration">Reference configuration&lt;/h2>
&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">general_settings&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="c"># only with a licence; without one, virtual keys&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">enable_jwt_auth&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">litellm_jwtauth&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">team_ids_jwt_field&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;groups&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">team_id_upsert&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">user_id_jwt_field&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;sub&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">user_id_upsert&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">end_user_id_jwt_field&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;preferred_username&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">user_allowed_email_domain&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;ejemplo.es&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">admin_jwt_scope&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;litellm_proxy_admin&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="c"># the three that ship switched off&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">enforce_rbac&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">enforce_scope_based_access&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">enforce_team_based_model_access&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">allow_requests_on_db_unavailable&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kc">false&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">proxy_batch_write_at&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">60&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">litellm_settings&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">callbacks&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;langfuse_otel&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;prometheus&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="c"># without this, the engine does not know who is calling&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">add_user_information_to_llm_headers&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">turn_off_message_logging&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">cache&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">cache_params&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">redis&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">host&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">os.environ/REDIS_HOST&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">namespace&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">litellm&lt;/span>&lt;span class="w">
&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-bash" data-lang="bash">&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># the two that enable the validation that by default does not happen&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="nv">JWT_AUDIENCE&lt;/span>&lt;span class="o">=&lt;/span>litellm-gateway
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="nv">JWT_ISSUER&lt;/span>&lt;span class="o">=&lt;/span>https://sso.ejemplo.es/realms/plataforma
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="nv">JWT_PUBLIC_KEY_URL&lt;/span>&lt;span class="o">=&lt;/span>https://sso.ejemplo.es/realms/plataforma/.well-known/openid-configuration
&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="nv">LANGFUSE_HOST&lt;/span>&lt;span class="o">=&lt;/span>http://langfuse-web.observabilidad.svc:3000
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="nv">LANGFUSE_PUBLIC_KEY&lt;/span>&lt;span class="o">=&lt;/span>...
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="nv">LANGFUSE_SECRET_KEY&lt;/span>&lt;span class="o">=&lt;/span>...
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>And on the Langfuse side:&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">AUTH_KEYCLOAK_CLIENT_ID&lt;/span>&lt;span class="o">=&lt;/span>langfuse
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="nv">AUTH_KEYCLOAK_ISSUER&lt;/span>&lt;span class="o">=&lt;/span>https://sso.ejemplo.es/realms/plataforma
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="nv">AUTH_KEYCLOAK_ALLOW_ACCOUNT_LINKING&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">AUTH_DISABLE_USERNAME_PASSWORD&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">AUTH_DISABLE_SIGNUP&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_DEFAULT_ORG_ID&lt;/span>&lt;span class="o">=&lt;/span>plataforma
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="nv">LANGFUSE_DEFAULT_ORG_ROLE&lt;/span>&lt;span class="o">=&lt;/span>VIEWER
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="nv">ENCRYPTION_KEY&lt;/span>&lt;span class="o">=&lt;/span>... &lt;span class="c1"># 64 hexadecimal characters&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h2 id="checklist">Checklist&lt;/h2>
&lt;ol>
&lt;li>Define &lt;code>JWT_AUDIENCE&lt;/code> and &lt;code>JWT_ISSUER&lt;/code> on day one, or use the per-issuer configuration. Without that, neither audience nor issuer is validated.&lt;/li>
&lt;li>Set &lt;code>enforce_rbac&lt;/code>, &lt;code>enforce_scope_based_access&lt;/code> and &lt;code>enforce_team_based_model_access&lt;/code> to true, and test that a token from another client of the same realm is rejected.&lt;/li>
&lt;li>Decide where the end user of the traces comes from and write it into the design. If it is not decided, traces come out with no person.&lt;/li>
&lt;li>Separate Postgres. Separate Redis. If Redis is shared, different prefix and logical database, and review the memory policy.&lt;/li>
&lt;li>Check that the proxy starts with Postgres down only if that is what you want, and that the on-call team can tell an admission 503 from a credential 401.&lt;/li>
&lt;li>Alert on &lt;code>litellm_service_latency&lt;/code> per service, not on Redis availability, because the circuit breaker hides the problem.&lt;/li>
&lt;li>One Langfuse project per tenant with the initialisation variables, and the per-team keys in &lt;code>callback_vars&lt;/code>.&lt;/li>
&lt;li>Put a collector in the middle if you need an on-disk queue, tail sampling or attribute redaction.&lt;/li>
&lt;li>Check that the gateway version is above 1.83.14 because of the earlier critical vulnerabilities.&lt;/li>
&lt;li>Write into the risk analysis that virtual keys are credentials decoupled from the identity provider, and define the offboarding procedure.&lt;/li>
&lt;/ol>
&lt;h2 id="traps-and-things-that-are-not-what-they-seem">Traps and things that are not what they seem&lt;/h2>
&lt;p>&lt;strong>A properly signed JWT is not a JWT addressed to this service.&lt;/strong> Without the two environment variables, the audience is not checked.&lt;/p>
&lt;p>&lt;strong>&lt;code>enforce_rbac&lt;/code> set to false does not mean there is no authorisation&lt;/strong>, it means that authorisation comes only from team membership, if there is a team claim. With no team claim and without the flag, a valid token reaches &lt;code>/chat/completions&lt;/code>.&lt;/p>
&lt;p>&lt;strong>The stale copy of the public keys lasts up to seventy minutes&lt;/strong>, adding the two TTLs. Revoking a key at the provider does not take effect immediately.&lt;/p>
&lt;p>&lt;strong>With a single key in the JWKS and no key identifier, the identifier is not checked.&lt;/strong>&lt;/p>
&lt;p>&lt;strong>The trace user is set by the client.&lt;/strong> Any per-person spend report built on that field is a declaration, not a measurement.&lt;/p>
&lt;p>&lt;strong>Redis going down generates no errors&lt;/strong>, it generates per-pod limits. It is the most expensive of the failures that give no warning.&lt;/p>
&lt;p>&lt;strong>Langfuse does not start without ClickHouse or without an S3 bucket.&lt;/strong> Its failure surface is bigger than the gateway&amp;rsquo;s, and that changes how the high availability effort is shared out.&lt;/p>
&lt;p>&lt;strong>Langfuse&amp;rsquo;s per-project access control is paid for in self-hosted&lt;/strong>, as are audit logs. For an ENS file, that gets decided before building, not after.&lt;/p>
&lt;p>&lt;strong>Sharing Redis does not break through key collision, it breaks through memory policy.&lt;/strong> It is a failure that shows up under load and gets diagnosed badly.&lt;/p>
&lt;p>&lt;strong>Langfuse&amp;rsquo;s ingestion route is OTLP over HTTP only.&lt;/strong> A collector configured with the gRPC exporter points at somewhere that does not exist.&lt;/p>
&lt;h2 id="closing">Closing&lt;/h2>
&lt;p>The five seams share a common pattern: they fail without warning. The token from another service gets in, the trace user comes from the client, the rate limit stops being global, the spend queue discards on reaching 64 MB and the Langfuse project does not create itself. None of those five things produces a visible error.&lt;/p>
&lt;p>Day 2 work, then, consists above all of turning silences into signals. Two environment variables so that the audience is validated, three flags so that authorisation is real, an alert on service latency instead of on Redis availability, and an explicit contract on where the end user&amp;rsquo;s identity comes from.&lt;/p>
&lt;p>And one decision worth taking early, before the budget is closed. The gateway&amp;rsquo;s JWT authentication and Langfuse&amp;rsquo;s per-project access control are both behind a licence. A sovereign platform can live perfectly well without either, with virtual keys and one project per tenant, but it is a different architecture and it has to be drawn as such from the start.&lt;/p>
&lt;h2 id="see-also">See also&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/litellm-langfuse-operational-pair/">LiteLLM and Langfuse: the operational pair&lt;/a>: the seam between gateway and traces, with the four queues that discard.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/sizing-agents-decode-bottleneck/">Sizing for agents&lt;/a>: the previous article in the track, with the size of each piece.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/keycloak-identity-plane-ai-platform/">Keycloak in an AI platform&lt;/a>: the identity piece from the inside and its position in the architecture.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/litellm-mcp-gateway-second-front-door/">LiteLLM&amp;rsquo;s MCP gateway&lt;/a>: the second door, with its permissions and its privacy problem.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/langfuse-inside-sorting-centre-bottleneck/">Langfuse from the inside&lt;/a> and &lt;a href="https://blog.lo0.es/en/posts/langfuse-v4-what-goes-into-a-trace/">what goes into a trace&lt;/a>.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/hardening-secrets-sovereign-llm-stack-defence-in-depth/">Hardening and secrets of the sovereign LLM stack&lt;/a>: the default-deny method that orders the port table.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/mcp-grows-up-authentication-keycloak/">When MCP grows: adding authentication with Keycloak&lt;/a>.&lt;/li>
&lt;/ul>
&lt;h2 id="sources">Sources&lt;/h2>
&lt;ul>
&lt;li>Código de LiteLLM 1.102.0, commit &lt;code>9071ca50&lt;/code> del 11 de septiembre de 2026.&lt;/li>
&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/docs/administration/authentication-and-sso">Autenticación y SSO en Langfuse&lt;/a>, consultado el 12 de septiembre de 2026.&lt;/li>
&lt;li>&lt;a href="https://langfuse.com/docs/administration/scim-and-org-api">SCIM y API de organización&lt;/a>, consultado el 12 de septiembre de 2026.&lt;/li>
&lt;li>&lt;a href="https://langfuse.com/integrations/native/opentelemetry">Integración OpenTelemetry de Langfuse&lt;/a>, consultado el 12 de septiembre de 2026.&lt;/li>
&lt;li>&lt;a href="https://langfuse.com/self-hosting/deployment/infrastructure/postgres">Postgres&lt;/a> y &lt;a href="https://langfuse.com/self-hosting/deployment/infrastructure/cache">caché&lt;/a> en la documentación de self-hosting, consultados el 12 de septiembre de 2026.&lt;/li>
&lt;li>&lt;a href="https://docs.litellm.ai/docs/proxy/token_auth">Autenticación por token en LiteLLM&lt;/a> y &lt;a href="https://docs.litellm.ai/docs/proxy/config_settings">ajustes de configuración&lt;/a>, consultados el 12 de septiembre de 2026.&lt;/li>
&lt;li>&lt;code>GHSA-jjhc-v7c2-5hh6&lt;/code> y registro de &lt;code>CVE-2026-35030&lt;/code> en NVD.&lt;/li>
&lt;/ul></description></item><item><title>LiteLLM's MCP gateway: the second front door, the tool catalogue nobody bills, and why filtering it can make selection worse</title><link>https://blog.lo0.es/en/posts/litellm-mcp-gateway-second-front-door/</link><pubDate>Fri, 11 Sep 2026 01:45:00 +0200</pubDate><guid>https://blog.lo0.es/en/posts/litellm-mcp-gateway-second-front-door/</guid><description>&lt;blockquote>
&lt;p>Sixth post in the operational track on the control layer. The previous five treat the gateway as the piece the tokens pass through: &lt;a href="https://blog.lo0.es/en/posts/litellm-langfuse-operational-pair/">the pair with Langfuse&lt;/a>, &lt;a href="https://blog.lo0.es/en/posts/litellm-day-2-high-availability/">day 2&lt;/a>, &lt;a href="https://blog.lo0.es/en/posts/litellm-virtual-keys-budgets-limits/">virtual keys&lt;/a>, &lt;a href="https://blog.lo0.es/en/posts/humans-agents-same-gateway-priority-traffic-separation/">humans and agents&lt;/a> and &lt;a href="https://blog.lo0.es/en/posts/prefix-routing-what-litellm-does-not-do/">prefix routing&lt;/a>. This one is about the other door. Verified against LiteLLM 1.102.0, commit of 10 September 2026.&lt;/p>
&lt;/blockquote>
&lt;h2 id="tldr">TL;DR&lt;/h2>
&lt;p>&lt;strong>The MCP gateway is not a module, it is a complete second surface.&lt;/strong> A native MCP endpoint with four route spellings, a proxy mode with three fixed tools, four REST routes, some thirty-five administration routes and a long set of OAuth discovery endpoints. It still lives under &lt;code>_experimental&lt;/code> and it is nearly 20,000 lines.&lt;/p>
&lt;p>&lt;strong>I correct what I said in the previous post: access is NOT open by default.&lt;/strong> In 1.102.0, a key with no declared permissions and no team resolves zero servers. What does open things up is a per-server flag, &lt;code>allow_all_keys&lt;/code>, and that flag &lt;strong>overrides the key&amp;rsquo;s explicit scope&lt;/strong> unless you enable a setting that is off by default.&lt;/p>
&lt;p>&lt;strong>Permissions are enforced on execution, not just on the listing.&lt;/strong> Calling a tool that did not appear in &lt;code>tools/list&lt;/code> returns 403. There are three chained checks before the call, including one on the allowed arguments. The same predicate governs listing and calling, by declared design.&lt;/p>
&lt;p>&lt;strong>The hierarchy is built out of intersections, with two escapes and a fail-open.&lt;/strong> The key&amp;rsquo;s access groups are additive on top of the key and team ceiling. The organisation ceiling replaces rather than intersects when there is nothing below it. And on an indeterminate error while resolving tool permissions, the code returns &amp;ldquo;unrestricted&amp;rdquo;. It is a deliberate decision and it has to be declared in the risk analysis.&lt;/p>
&lt;p>&lt;strong>Per-tool cost is worth zero and that is why its metric disappears.&lt;/strong> There are exactly two Prometheus metrics for MCP, and the spend one is only incremented if the cost is greater than zero. With the default configuration, that series never exists. There is no listing metric, no latency metric, no error metric, no server health metric.&lt;/p>
&lt;p>&lt;strong>The cost that does matter is not attributed to MCP anywhere.&lt;/strong> It is the tokens of the tool definitions, resent on every turn inside the &lt;code>prompt_tokens&lt;/code> of the model call. Five typical MCP servers add up to 58 tools and some 55,000 tokens. The tool&amp;rsquo;s spend row, with its zero cost, has nothing to do with that.&lt;/p>
&lt;p>&lt;strong>And the gateway&amp;rsquo;s semantic filter does not act on the MCP door.&lt;/strong> It only applies when it is LiteLLM calling the model. A client connected to &lt;code>/mcp&lt;/code> receives the whole catalogue, filter enabled or not. And even if it did act, filtering per request rewrites the cached prefix of the prompt, which is the opposite of what Anthropic and OpenAI do with their own tool searches.&lt;/p>
&lt;p>&lt;strong>The good observability ships switched off.&lt;/strong> Without &lt;code>LITELLM_OTEL_V2&lt;/code>, a tool call comes out as a generic span with the model set to &lt;code>MCP: name&lt;/code>. With it enabled there is a dedicated span and correct attributes, and the arguments and the result stay hidden unless you opt in. And in neither case is trace context propagated towards the upstream MCP server: the trace is cut at the gateway.&lt;/p>
&lt;p>&lt;strong>Tool arguments are written, and they are written in the clear.&lt;/strong> They go in full into the &lt;code>metadata&lt;/code> column of the spend table, without passing through the switch that governs prompt storage nor through the redaction function, which only knows about &lt;code>messages&lt;/code> and &lt;code>response&lt;/code>. Whoever enabled redaction for GDPR probably believes they are covered.&lt;/p>
&lt;p>&lt;strong>The 1.83 branch concentrated four serious vulnerabilities, and one of them belongs to the MCP gateway itself.&lt;/strong> The endpoint for testing a connection before saving accepted the stdio transport&amp;rsquo;s command, args and environment in the request body. A low-privilege key got command execution on the proxy host.&lt;/p>
&lt;p>&lt;strong>And the specification moved underneath.&lt;/strong> The revision in force since July 2026 removed protocol sessions, the initialisation handshake and the session header. LiteLLM 1.102.0 advertises the June 2025 revision and its version enumeration does not even contemplate the two later ones.&lt;/p>
&lt;h2 id="you-are-here-the-door-nobody-chose">You are here: the door nobody chose&lt;/h2>
&lt;p>The gateway was chosen for the reasons in &lt;a href="https://blog.lo0.es/en/posts/choosing-oss-gateway-llm-inference/">the switchboard post&lt;/a>: licence, fit inside the cluster, maturity. What was evaluated was the inference traffic.&lt;/p>
&lt;p>Later, somebody connects an agent, the agent needs tools, and the same process that is already authenticated and deployed turns out to speak MCP too. The decision to turn it into the tool door is almost never taken: it is inherited. And that door has neither the same controls, nor the same observability, nor the same cost model.&lt;/p>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/mcp-grows-up-authentication-keycloak/">When MCP grows&lt;/a> covered the identity of your own MCP servers. &lt;a href="https://blog.lo0.es/en/posts/mcp-deep-observability-opentelemetry/">MCP from the inside&lt;/a> covered the protocol and its instrumentation. This post covers the piece in between.&lt;/p>
&lt;h2 id="the-analogy-the-switchboard-that-also-hands-out-keys">The analogy: the switchboard that also hands out keys&lt;/h2>
&lt;p>An office building&amp;rsquo;s telephone switchboard. Its original job is to route calls: it decides who can call outside, how much each department spends, and it leaves a record of every conference call.&lt;/p>
&lt;p>Over time, the switchboard operator is also given the key cabinet. They are already at reception, they already know who everybody is, it looks like the natural place. And from that moment there are two services at the same desk with very different rules.&lt;/p>
&lt;p>Calls are priced by the minute and show up on the bill. Keys have no tariff, so on the department&amp;rsquo;s bill they come out at zero, and somebody might conclude that the key cabinet costs nothing. It does cost: it costs the operator&amp;rsquo;s time, it costs having to read the whole key catalogue every time somebody asks what is available, and it costs that nobody checks that the label on a key still says what it said the day its loan was authorised.&lt;/p>
&lt;p>The analogy ends at the interesting spot. When somebody takes a key, the record says which key it was and what they said they wanted it for. That record is simultaneously the evidence of the action and a piece of data that perhaps should not be stored in the clear.&lt;/p>
&lt;h2 id="the-real-routes">The real routes&lt;/h2>
&lt;p>The first thing that surprises you when you look at the code is how many spellings get you to the same place.&lt;/p>
&lt;h3 id="the-native-endpoint">The native endpoint&lt;/h3>
&lt;p>The MCP sub-application is mounted at &lt;code>/mcp&lt;/code> and inside it has four mounts: the root as a wildcard, &lt;code>/mcp&lt;/code>, &lt;code>/{name}/mcp&lt;/code> and &lt;code>/sse&lt;/code>.&lt;/p>
&lt;p>And there is also a hand-declared route for bare &lt;code>/mcp&lt;/code> on the main application, with a comment that explains why: the mount cannot match its own bare prefix, and the resulting 307 redirect breaks MCP clients sitting behind a proxy that terminates TLS. It is the kind of detail you discover in production on a Friday.&lt;/p>
&lt;p>&lt;code>/mcp/sse&lt;/code> holds a surprise. It does not use the classic SSE transport: it uses the same HTTP session manager in stateless mode. The &lt;code>SseServerTransport&lt;/code> object is constructed and is not referenced on any other line of the file. It is dead code, and the &lt;code>/mcp/sse/messages&lt;/code> endpoint that this object advertises does not exist as a working route.&lt;/p>
&lt;p>The &lt;code>/{name}/mcp&lt;/code> route resolves in a documented order: server alias, comma-separated list with a maximum of 16 elements, tool set, access group. If nothing matches, 404.&lt;/p>
&lt;p>And &lt;code>/mcp/proxy&lt;/code> exposes a fixed surface of three tools. It is the progressive disclosure pattern, and it will come up again further down because it is the most important mitigation in this post.&lt;/p>
&lt;h3 id="the-rest-routes">The REST routes&lt;/h3>
&lt;p>Four, under the &lt;code>/mcp-rest&lt;/code> prefix:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Method&lt;/th>
&lt;th>Route&lt;/th>
&lt;th>Authentication&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>GET&lt;/td>
&lt;td>&lt;code>/mcp-rest/tools/list&lt;/code>&lt;/td>
&lt;td>Virtual key&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>POST&lt;/td>
&lt;td>&lt;code>/mcp-rest/tools/call&lt;/code>&lt;/td>
&lt;td>Virtual key&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>POST&lt;/td>
&lt;td>&lt;code>/mcp-rest/test/connection&lt;/code>&lt;/td>
&lt;td>Key plus administrator role&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>POST&lt;/td>
&lt;td>&lt;code>/mcp-rest/test/tools/list&lt;/code>&lt;/td>
&lt;td>Key plus administrator role&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>The variants without &lt;code>-rest&lt;/code> that appear in the internal types have no router registering them. The &lt;code>/mcp/*&lt;/code> wildcard absorbs them as JSON-RPC.&lt;/p>
&lt;p>The last two are the stars of the security section.&lt;/p>
&lt;h3 id="administration-and-discovery">Administration and discovery&lt;/h3>
&lt;p>Some thirty-five routes under &lt;code>/v1/mcp&lt;/code>, almost all of them with virtual key authentication. Three details worth knowing:&lt;/p>
&lt;p>&lt;code>GET /v1/mcp/registry.json&lt;/code> &lt;strong>carries no authentication dependency&lt;/strong>. It returns 404 if the public registry is not enabled, which is the real protection.&lt;/p>
&lt;p>&lt;code>POST /v1/mcp/server&lt;/code> requires the proxy administrator role. &lt;code>POST /v1/mcp/server/register&lt;/code> does not: it is the proposal path, which requires a key with a team, explicitly forbids the stdio transport and leaves the entry pending review.&lt;/p>
&lt;p>And on top of that there is a long set of OAuth &lt;code>.well-known&lt;/code> endpoints, with dynamic client registration, authorisation, token, revocation and introspection, plus their per-server variants.&lt;/p>
&lt;h3 id="configuration-and-transport">Configuration and transport&lt;/h3>
&lt;p>The &lt;code>mcp_servers&lt;/code> schema in &lt;code>config.yaml&lt;/code> has fifty-odd fields, all of them optional. A warning for anyone coming from earlier versions: &lt;strong>&lt;code>spec_version&lt;/code> no longer exists&lt;/strong> in 1.102.0.&lt;/p>
&lt;p>The default transport when omitted is &lt;code>http&lt;/code>, that is, streamable HTTP. The other two values are &lt;code>sse&lt;/code> and &lt;code>stdio&lt;/code>. The string &lt;code>streamable_http&lt;/code> is not valid in the configuration file, although it is accepted as a synonym when importing connector files.&lt;/p>
&lt;p>Tool prefixing uses the format &lt;code>{server}{separator}{tool}&lt;/code> with &lt;code>-&lt;/code> as the default separator, configurable through an environment variable read at import time. The prefix chosen is the alias, or the name, or the identifier, in that order. There is an optional short mode that derives three characters from the hash of the server identifier and resolves collisions by rehashing.&lt;/p>
&lt;h2 id="access-control-and-the-correction-to-the-previous-post">Access control, and the correction to the previous post&lt;/h2>
&lt;p>In &lt;a href="https://blog.lo0.es/en/posts/humans-agents-same-gateway-priority-traffic-separation/">the humans and agents post&lt;/a> I closed the list of traps with this one: &amp;ldquo;access to MCP servers is open by default if no level defines a list&amp;rdquo;. Verified in 1.102.0, that is false.&lt;/p>
&lt;p>The function that resolves the allowed servers does the opposite. If the team declares nothing, it inherits from the key; if the key declares nothing either, the base set is empty and the scope is marked as unbounded with zero servers. There is no branch that expands to &amp;ldquo;the whole registry&amp;rdquo; other than the administration role one.&lt;/p>
&lt;p>What does open things up, and this is the important part, is something else.&lt;/p>
&lt;h3 id="allow_all_keys">&lt;code>allow_all_keys&lt;/code>&lt;/h3>
&lt;p>It is a per-server flag, in configuration, in the API and in the database, defaulting to false. When set to true, that server joins the set of open servers and any key can see it.&lt;/p>
&lt;p>The detail to know: &lt;strong>by default it overrides the key&amp;rsquo;s explicit scope&lt;/strong>. A key that declares access only to server A will also see server B if B has the flag. The key&amp;rsquo;s scope is only respected if you enable &lt;code>general_settings.mcp_allow_all_keys_respects_mcp_scope&lt;/code>, which is off by default.&lt;/p>
&lt;p>There is a second opening case: anonymous subjects, with no user identifier, role or key, additionally receive the servers configured with authentication delegation towards the upstream and the pass-through ones. It is consistent with the design of those two modes, and it is a surface you have to know about.&lt;/p>
&lt;h3 id="require_key_mcp_access_defined">&lt;code>require_key_mcp_access_defined&lt;/code>&lt;/h3>
&lt;p>It lives in &lt;code>general_settings&lt;/code>, defaults to false, and needs no licence. What it inverts is &lt;strong>only&lt;/strong> the empty-key inheritance towards the team: with the flag on, a key with no list of its own gets an empty set instead of inheriting the team&amp;rsquo;s.&lt;/p>
&lt;p>It does not affect keys that do declare servers, which still intersect with the team. And it does not close off the access group grants, which are additive and are the escape route you have to audit separately.&lt;/p>
&lt;p>There is a sibling, &lt;code>require_end_user_mcp_access_defined&lt;/code>, with the same shape and applied to the end user.&lt;/p>
&lt;h3 id="the-hierarchy">The hierarchy&lt;/h3>
&lt;p>The function&amp;rsquo;s docstring is normative and says that all the rules are intersections. In detail:&lt;/p>
&lt;ul>
&lt;li>Key and team: intersection when both declare. Inheritance in one direction when one is empty.&lt;/li>
&lt;li>Key access groups: &lt;strong>additive union&lt;/strong> on top of the previous result.&lt;/li>
&lt;li>End user: intersection if it declares anything.&lt;/li>
&lt;li>Agent: intersection.&lt;/li>
&lt;li>Internal user: ceiling that only narrows.&lt;/li>
&lt;li>Organisation: ceiling that intersects if there are restrictions below, and that &lt;strong>replaces&lt;/strong> by becoming the ceiling when there are none.&lt;/li>
&lt;/ul>
&lt;p>For subjects with no key, admitted by SSO or by the gateway, the model changes to a per-source union instead of an intersection.&lt;/p>
&lt;h3 id="the-fail-open">The fail-open&lt;/h3>
&lt;p>This is the point you have to take to the risk analysis and not leave in a technical note.&lt;/p>
&lt;p>On an indeterminate failure while resolving the tool permissions of a key or a JWT, the code returns &amp;ldquo;unrestricted&amp;rdquo;, that is, all tools allowed. The organisation ceiling is skipped in the same situation. For subjects with no key the behaviour is the opposite, fail-closed.&lt;/p>
&lt;p>There is a well-resolved intermediate case: when a permission is named but cannot be read, a specific error is raised and it is denied in both cases.&lt;/p>
&lt;p>The doctrine is documented in the class docstring, which is appreciated. And it remains a behaviour that, in a system under the ENS, you have to declare in writing instead of discovering it during an audit.&lt;/p>
&lt;h3 id="what-is-done-well">What is done well&lt;/h3>
&lt;p>Permissions are enforced on the call, not just on the listing. The execution path chains three checks before contacting the server: allowed or forbidden tools, per key and team tool permission, and validation of the arguments against the allowed ones. All three return 403.&lt;/p>
&lt;p>Listing and calling share the same predicate, and the docstring declares it as an explicit invariant. A client that calls a tool directly when it did not appear in its listing receives 403, it does not execute. The same over REST and over the responses API route.&lt;/p>
&lt;p>The &lt;code>x-mcp-servers&lt;/code> and &lt;code>x-mcp-access-groups&lt;/code> headers only narrow, and they fail closed if they do not resolve.&lt;/p>
&lt;p>Header forwarding towards the upstream is an explicit per-server allowlist, with a centralised decision about when to strip the caller&amp;rsquo;s &lt;code>Authorization&lt;/code>, and a protection against cross-forwarding: in a multi-server listing, the global header is withheld if more than one server would consume it. That is RFC 9700 applied.&lt;/p>
&lt;h2 id="the-bill-that-never-shows-up">The bill that never shows up&lt;/h2>
&lt;p>Here is, in my opinion, the most important thing in this post, and it has nothing to do with security.&lt;/p>
&lt;p>Every tool the gateway aggregates takes up space in the model&amp;rsquo;s context. Name, description and parameter schema travel in the request&amp;rsquo;s &lt;code>tools&lt;/code> block, &lt;strong>on every turn&lt;/strong>, and they are paid for as input tokens of the real model.&lt;/p>
&lt;p>The figures published by Anthropic in November 2025 are the best public reference there is. Five connected MCP servers add up to 58 tools and some 55,000 tokens. The breakdown: GitHub, 35 tools and some 26,000 tokens. Slack, 11 tools and some 21,000. Sentry, five and some 3,000. Grafana, five and some 3,000. Splunk, two and some 2,000.&lt;/p>
&lt;p>With tool search enabled, that set drops from some 72,000 to some 8,700 tokens, and the usable context goes from 122,800 to 191,300.&lt;/p>
&lt;p>The second cost of a large catalogue is not economic and is not billed: it is that the model chooses worse. It has its own section further down, because of the two it is the one that decides whether the agent is any good.&lt;/p>
&lt;h3 id="how-to-measure-it-with-what-is-already-there">How to measure it with what is already there&lt;/h3>
&lt;p>The gateway stores enough data, even if it does not cross-reference it for you.&lt;/p>
&lt;p>In the listing&amp;rsquo;s spend row, with call type &lt;code>list_mcp_tools&lt;/code>, it enriches the metadata with &lt;code>allowed_server_count&lt;/code>, &lt;code>tool_count_total&lt;/code>, &lt;code>per_server_tool_counts&lt;/code> and &lt;code>per_server_list_outcomes&lt;/code>.&lt;/p>
&lt;p>In the model call rows, the &lt;code>proxy_server_request&lt;/code> column keeps the request body with the &lt;code>tools&lt;/code> block inside it.&lt;/p>
&lt;p>The practical method: pair the listing rows with the LLM rows of the same session by &lt;code>session_id&lt;/code>, read the total number of tools from the first, and pass the &lt;code>tools&lt;/code> array of the second through &lt;code>litellm.token_counter(model=..., tools=...)&lt;/code>, which accepts that argument. Multiply by the number of turns, because the block is resent on all of them.&lt;/p>
&lt;p>For the relative cost, a quicker comparison: mean &lt;code>prompt_tokens&lt;/code> of keys with MCP enabled against those without, same model and same period.&lt;/p>
&lt;h2 id="tool-selection">Tool selection&lt;/h2>
&lt;p>The previous section treats the catalogue as a bill. This one treats it as what determines whether the agent gets it right. This is ground where the talk runs well ahead of the measurement, so it is worth separating what has been measured from what gets repeated.&lt;/p>
&lt;h3 id="how-many-tools-a-model-can-take">How many tools a model can take&lt;/h3>
&lt;p>Anthropic&amp;rsquo;s official figure is that Claude&amp;rsquo;s ability to pick the right tool degrades when going from 30 to 50 available tools. The same documentation gives the inverse criterion, which is the more useful one: the full catalogue with nothing else is the right option with fewer than ten tools, when all of them are used on every request, or when the definitions add up to fewer than a hundred tokens.&lt;/p>
&lt;p>There is also an administrative ceiling before the semantic one. VS Code with Copilot cuts off at &lt;strong>128 tools per request&lt;/strong> and returns an error when you exceed it.&lt;/p>
&lt;p>The full curve gets cited far more than it has been measured. The soundest primary data I found is from May 2025: varying the number of candidates from 1 to 11,100 over a registry of more than 4,400 servers, below some 30 tools the success rate is above 90 %, between 31 and 70 intermittent failures appear, and past the hundred mark the degradation becomes dominated by retrieval. It is a qualitative heat map, not a numeric table.&lt;/p>
&lt;p>The figure that circulates most (78 % with ten tools, 40 % with a hundred, 13.6 % with seven hundred) comes from a conference talk with no paper, no repository and no published methodology. Do not take it to a client presentation.&lt;/p>
&lt;p>And there is a warning that affects everything else. An audit of 496 tasks from four tool calling benchmarks found &lt;strong>18.5 % misalignment between the label and reality&lt;/strong>. In the BFCL v4 subset, 80 % of the failures assigned to the agent came from fragile state comparisons. In one of the benchmarks with an automatic judge, 23 identical re-runs gave a range from 57.9 % to 76.8 %, almost 19 points of spread, enough to reorder the ranking. Any absolute figure in this section deserves a generous margin of error.&lt;/p>
&lt;h3 id="how-it-fails">How it fails&lt;/h3>
&lt;p>The work with the cleanest taxonomy evaluates 36 servers, 220 tools and 1,000 tasks against twenty frontier models, with an average of 15.2 tools exposed per task of which 4.1 are relevant.&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Failure mode&lt;/th>
&lt;th>Share&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Not using a tool when one was needed&lt;/td>
&lt;td>10.5 %&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Choosing the wrong tool&lt;/td>
&lt;td>9.0 %&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Malformed parameters&lt;/td>
&lt;td>6.9 %&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Not recovering from an error&lt;/td>
&lt;td>10.3 %&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Cognitive failures, not tool failures&lt;/td>
&lt;td>63.3 %&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>The interesting part is in the per-model breakdown: &lt;strong>the dominant failure mode changes with the model, not with the size of the catalogue&lt;/strong>. In one of them, 40.1 % of the failures are simply not calling any tool. In others the malformed parameter dominates. In another, the wrong tool. There is no canonical failure to attack.&lt;/p>
&lt;p>On tools that do the same thing, which is the real case of a gateway with fifteen servers, there is a good measurement. A study with ten groups of five functionally equivalent tools and a thousand query pairs puts the selection bias between 0.3 and 0.4, that is, &lt;strong>you would have to redistribute between 30 and 40 % of the probability mass for equivalent tools to be chosen equally&lt;/strong>. The same work shows that a biased continued training takes the selection of a specific endpoint from 0.6 % to 12.8 %.&lt;/p>
&lt;p>That study contributes another figure that reorders priorities: shuffling a tool&amp;rsquo;s &lt;strong>description&lt;/strong> moves the selection distribution substantially, whereas &lt;strong>changing only the name has minimal and inconsistent effects&lt;/strong>. Applied to the gateway&amp;rsquo;s &lt;code>server-tool&lt;/code> prefixing: it costs tokens and probably does not change which tool the model goes to. There is no study isolating the effect of name spacing, and it is a real gap in the literature.&lt;/p>
&lt;p>If the description is what carries weight, the state of the estate is the problem. An audit of 856 tools from 103 MCP servers found undeclared limitations in 89.8 %, missing usage guidance in 89.3 % and opaque parameters in 84.3 %. &lt;strong>Only 2.9 % were free of problems across all their components.&lt;/strong>&lt;/p>
&lt;h3 id="the-optimum-is-not-a-number">The optimum is not a number&lt;/h3>
&lt;p>The May 2026 work I cited above proposes a chance-corrected metric and uses it to size the list adaptively. Over 370 functions it reaches 90.3 % coverage while seeing an average of 7.4 tools, against 90.8 % seeing a fixed 50.&lt;/p>
&lt;p>But the same paper publishes its own counterexample, and it is honest to cite it: over another benchmark of 3,251 tools, &lt;strong>the fixed K of 5 wins in aggregate&lt;/strong>, 64.7 % against 61.9 %. The adaptive advantage concentrates in the hard queries, where the right tool sits between sixth and twentieth by similarity: there it finds 16.7 % of the cases where the fixed K finds 0 %.&lt;/p>
&lt;p>The March 2026 work over 121 tools puts the plateau at K=3, with 97.1 % that does not improve going up to K=5. With the caveat that it measures retrieval against labels, not model accuracy.&lt;/p>
&lt;p>And a July 2026 work gives the formulation that seems to me the right one: ranking by score is inconsistent with optimal acquisition when costs are heterogeneous, so &lt;strong>the optimal K is a function of cost, not a constant&lt;/strong>. Trimming from 7 to 4.4 exposed tools while keeping success is possible; fixing a universal number is not.&lt;/p>
&lt;h3 id="filtering-can-make-the-result-worse">Filtering can make the result worse&lt;/h3>
&lt;p>This is the part that appears in no product blog. A June 2026 benchmark with three registry sizes (25, 100 and 250 tools), six filtering methods, seven models and 26,460 runs:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Strategy&lt;/th>
&lt;th>Success&lt;/th>
&lt;th>Visible tools&lt;/th>
&lt;th>Tokens&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Expose the whole catalogue&lt;/td>
&lt;td>32.1 %&lt;/td>
&lt;td>125.00&lt;/td>
&lt;td>56,062&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Keyword, top-5&lt;/td>
&lt;td>22.1 %&lt;/td>
&lt;td>4.80&lt;/td>
&lt;td>3,200&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Keyword, top-10&lt;/td>
&lt;td>22.4 %&lt;/td>
&lt;td>9.54&lt;/td>
&lt;td>5,356&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>State-aware&lt;/td>
&lt;td>24.0 %&lt;/td>
&lt;td>25.95&lt;/td>
&lt;td>13,522&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Full causal path&lt;/td>
&lt;td>24.0 %&lt;/td>
&lt;td>26.24&lt;/td>
&lt;td>13,697&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>&lt;strong>Four of the five filtering strategies perform worse than not filtering.&lt;/strong> The fifth, which does win comfortably, exposes 0.99 tools per step and requires hand-written precondition and effect contracts for every tool, plus a search of the causal path to the goal state. It is a planner with the goal given, not a retrieval filter, and it is not comparable with anything a gateway offers.&lt;/p>
&lt;p>There is more evidence in the same direction. A work over a corpus of 43,000 tools measured that the agent&amp;rsquo;s completion rate drops when using retrieved sets against hand-annotated sets: the filter introduces its own recall ceiling. And top-K breaks the tasks that need several tools: on a benchmark of 16,464 endpoints, a fine-tuned retriever reaches a Recall@3 of 68.6 % but only 39.7 % of the queries have their &lt;strong>complete&lt;/strong> tool set within the first three. Almost 29 points of gap between &amp;ldquo;the one I needed is there&amp;rdquo; and &amp;ldquo;all the ones I needed are there&amp;rdquo;.&lt;/p>
&lt;p>Filtering is not free on security either. A March 2026 work attacks the retrieval layer by injecting adversarial tools that saturate the top-k: with injection rates of 1.2 % it manages to dominate the top-k between 91 % and 97 % of the time. It is an attack surface that exposing the whole catalogue does not have.&lt;/p>
&lt;p>The most elegant counterpoint comes from Anthropic&amp;rsquo;s own numbers. With the same catalogue of more than fifty tools, the gain from tool search drops from 25 points on one model to 8.6 on the next, because the unfiltered baseline rises from 49 % to 79.5 %. &lt;strong>The margin left by filtering shrank to a third in one generation of models.&lt;/strong> Any architecture decision taken here has a short shelf life.&lt;/p>
&lt;h3 id="the-prompt-cache-trap">The prompt cache trap&lt;/h3>
&lt;p>Here is what makes a homemade top-K filter a bad idea even if it worked.&lt;/p>
&lt;p>Tool definitions do not go just anywhere in the prompt: they go at the beginning. Anthropic documents the order of the cacheable prefix as &lt;code>tools&lt;/code>, then &lt;code>system&lt;/code>, then &lt;code>messages&lt;/code>, with a hierarchy where each level rests on the previous one. And the invalidation rule is explicit: &lt;strong>modifying tool definitions, be it names, descriptions or parameters, invalidates the entire cache&lt;/strong>, and a change at one level invalidates that level and all subsequent ones.&lt;/p>
&lt;p>OpenAI says the same and adds a detail: the prefix must remain unchanged, and &lt;strong>order counts&lt;/strong>. The same set of tools served in a different order is a cache miss.&lt;/p>
&lt;p>With vLLM serving an open model there is no tool block of its own. The &lt;code>tools&lt;/code> parameter is serialised by the chat template inside the first system message, behind the system prompt text. For automatic prefix caching the effect is identical, because it is still near the absolute start of the prompt.&lt;/p>
&lt;p>The consequence is the one that matters. A filter that returns a different set of tools on the next turn does not just invalidate the tool block: it invalidates &lt;strong>everything behind it&lt;/strong>, which is the system prompt and the whole conversation, which is exactly the part that grows with the turns. At list prices, a prefix token goes from costing 0.1 times when read from cache to 1.25 times when written. An agent with sixty thousand tokens of stable prefix goes from some six thousand equivalent tokens per turn to some seventy-five thousand, plus the prefill that gets paid again in latency.&lt;/p>
&lt;p>The two big providers have separately arrived at the same solution, and it is the opposite of filtering in the prefix. Anthropic excludes deferred tools from the prefix and, when the model discovers one through search, injects it inline inside the conversation: &amp;ldquo;the prefix stays intact, so prompt caching is preserved&amp;rdquo;. OpenAI: &amp;ldquo;all tools are loaded at the end of the model&amp;rsquo;s context window (&amp;hellip;) this allows the cache to be preserved from one request to the next&amp;rdquo;. Anthropic&amp;rsquo;s operational recommendation is the stable core pattern: leave the three to five most used tools undeferred.&lt;/p>
&lt;p>The closest academic work, from January 2026, states the problem without measuring it and recommends exactly that, keeping a fixed set of reusable general-purpose functions and implementing dynamic capability through code generation.&lt;/p>
&lt;p>The July 2026 MCP specification picks up both halves of the matter. Servers &amp;ldquo;&lt;strong>should&lt;/strong> return the tools from &lt;code>tools/list&lt;/code> in a deterministic order to allow caching in the client and improve the hit rate of the model&amp;rsquo;s prompt cache&amp;rdquo;. And it adds mandatory &lt;code>ttlMs&lt;/code> and &lt;code>cacheScope&lt;/code> in listing results, so that the client can freeze the catalogue for a known window instead of re-querying it and propagating any variation to the prompt.&lt;/p>
&lt;p>Nobody has publicly measured the cost in euros of this effect. The only attempt I found is a self-published audit of ten thousand turns that puts it at 2.4 % to 3.5 % of cache misses, with non-reproducible methodology. It is a gap in the literature, and the order of magnitude in the previous paragraph comes from applying list prices, not from a measurement.&lt;/p>
&lt;h3 id="and-now-litellm">And now, LiteLLM&lt;/h3>
&lt;p>With all of the above in mind, the gateway&amp;rsquo;s levers read differently.&lt;/p>
&lt;p>&lt;strong>The semantic filter does not act on the MCP endpoint.&lt;/strong> It is registered as a generic LiteLLM callback, and its only dispatcher is the proxy&amp;rsquo;s precall hook, which additionally discards anything that is not &lt;code>completion&lt;/code>, &lt;code>acompletion&lt;/code> or &lt;code>aresponses&lt;/code>. The &lt;code>tools/list&lt;/code> path does not invoke that hook at any point; there is a comment in the code that admits it in passing, when explaining why the listing&amp;rsquo;s JWT signature is done separately. An external MCP client connected to &lt;code>/mcp&lt;/code> receives the whole catalogue, filter enabled or not.&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Lever&lt;/th>
&lt;th>Serves external MCP clients&lt;/th>
&lt;th>Breaks the prompt cache&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Per-server &lt;code>allowed_tools&lt;/code>&lt;/td>
&lt;td>Yes&lt;/td>
&lt;td>No, it is static&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Name and description overrides&lt;/td>
&lt;td>Yes&lt;/td>
&lt;td>No, it is static&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Selection by route or header&lt;/td>
&lt;td>Yes&lt;/td>
&lt;td>No, if it is stable per agent&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>/mcp/proxy&lt;/code> and virtual tools&lt;/td>
&lt;td>Yes&lt;/td>
&lt;td>No, the surface is fixed&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Semantic filter&lt;/td>
&lt;td>&lt;strong>No&lt;/strong>&lt;/td>
&lt;td>Yes, it rewrites the block per request&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>When it does act, the filter has details you need to know before enabling it. It embeds only each tool&amp;rsquo;s description, with the name as a fallback if it is missing; the parameter schema does not go in. Of the query it embeds &lt;strong>only the last user message&lt;/strong>: on turn twelve of an agent conversation, that might be &amp;ldquo;yes, go ahead&amp;rdquo;. The tool embeddings are cached and indexed at start-up; the query&amp;rsquo;s is computed on every request, with a synchronous call inside a coroutine.&lt;/p>
&lt;p>Its failure modes are almost all open, which is the reasonable thing: if the embedding model does not respond, if nothing passes the threshold, if the names do not match the catalogue, it returns all the tools. The exception is context overflow, which fails closed with a 400, and which if it happens while building the index during start-up is memoised and makes &lt;strong>all&lt;/strong> subsequent requests fail until you restart. The defaults are &lt;code>text-embedding-3-small&lt;/code>, &lt;code>top_k&lt;/code> of 10 and a threshold of 0.3, under &lt;code>litellm_settings.mcp_semantic_tool_filter&lt;/code>.&lt;/p>
&lt;p>&lt;strong>&lt;code>/mcp/proxy&lt;/code> does serve external clients&lt;/strong>, and that is where its real value lies. It replaces the catalogue with three tools with opaque identifiers. How &lt;code>search_tools&lt;/code> searches deserves a line, because it is not what you would assume: by default &lt;strong>it is neither a regular expression nor a semantic match, it is a keyword counter&lt;/strong>, how many tokens of the query appear as a substring in the concatenated name and description. It only moves to embeddings if you configure a model in &lt;code>mcp_tool_search&lt;/code>. The result cap is hard-coded at five in this mode, and the client cannot ask for more. In exchange, &lt;code>call_tool&lt;/code> goes through the same permission checks as a normal call plus a validation of the argument schema that the normal route does not do at that point.&lt;/p>
&lt;p>There is a related variant: with &lt;code>mcp_tool_search_enabled&lt;/code> in the key&amp;rsquo;s permissions, the catalogue is replaced by four virtual tools, and there the search one does expose &lt;code>top_k&lt;/code> to the client.&lt;/p>
&lt;p>&lt;strong>The catalogue order is not deterministic, and that breaks the client&amp;rsquo;s cache without anybody filtering anything.&lt;/strong> The list of allowed servers comes from iterating a Python set of strings, so with the random hash seed the order differs between processes, between uvicorn workers and between restarts. There is no sorting anywhere in the listing path, and there is no tool cache: every &lt;code>tools/list&lt;/code> queries the upstreams live, so the order within each server also depends on what the upstream returns that time. The gateway does not offer today the guarantee that the July specification recommends. The fix, sorting the catalogue before serving it, fits on one line.&lt;/p>
&lt;p>Nor is there any pagination towards the client. The catalogue is returned whole, always.&lt;/p>
&lt;p>&lt;strong>And the lever almost nobody uses is the static one.&lt;/strong> Every MCP server accepts &lt;code>tool_name_to_display_name&lt;/code> and &lt;code>tool_name_to_description&lt;/code>, and those overrides &lt;strong>really do replace what is sent to the client&lt;/strong> in &lt;code>tools/list&lt;/code>, not just what the administrator sees in the interface. The &lt;code>tools/call&lt;/code> routing translates from the new name to the real one before any permission check. With that you can trim a four-hundred-token description written by a third party, rename an ambiguous tool so it does not compete with a similar one, and do it without touching the prompt cache, because it is static and holds for all turns.&lt;/p>
&lt;p>It is also the rug pull mitigation left pending from the security section: if the description the model sees is the one the operator wrote, whatever the upstream server changes stops reaching the context. Two limits. They are configured through the database or the management API, &lt;strong>not through &lt;code>config.yaml&lt;/code>&lt;/strong>. And the reverse name translation is by exact equality and is not disambiguated between servers, so two servers with the same display name route unstably, made worse by the non-deterministic order of the previous paragraph.&lt;/p>
&lt;p>Alongside that, per-server &lt;code>allowed_tools&lt;/code> remains the blunt and effective instrument: it filters the listing the client sees, reduces tokens, and is static.&lt;/p>
&lt;h3 id="the-ladder-in-order">The ladder, in order&lt;/h3>
&lt;ol>
&lt;li>&lt;strong>Trim.&lt;/strong> Per-server &lt;code>allowed_tools&lt;/code>, plus a per-key allowlist. Cheap, static, does not break the cache and reduces the permission surface at the same time.&lt;/li>
&lt;li>&lt;strong>Rewrite the descriptions&lt;/strong> with the overrides. That is where the evidence is, because the description carries weight and the name almost none, and along the way it pins the contract against the rug pull.&lt;/li>
&lt;li>&lt;strong>Split by route.&lt;/strong> Each agent to &lt;code>/{server}/mcp&lt;/code> or to a tool set, never to the aggregated endpoint. Stable per agent, so the cache survives.&lt;/li>
&lt;li>&lt;strong>Sort the catalogue&lt;/strong> before serving it if you control the deployment, while the gateway does not do it.&lt;/li>
&lt;li>&lt;strong>&lt;code>/mcp/proxy&lt;/code>&lt;/strong> when the catalogue goes past thirty or fifty tools. It is progressive disclosure and it works with external clients, which is more than you can say for the semantic filter.&lt;/li>
&lt;li>&lt;strong>The semantic filter&lt;/strong>, only when the one calling the model is LiteLLM. With a generous &lt;code>top_k&lt;/code>, knowing that it embeds only the last user message and that context overflow is a closed and sticky failure.&lt;/li>
&lt;li>&lt;strong>What not to do&lt;/strong>: a home-grown top-K filter that rewrites the &lt;code>tools&lt;/code> array on every turn. It costs the whole prefix and, according to four of the five measured strategies, probably makes selection worse.&lt;/li>
&lt;/ol>
&lt;h2 id="the-declared-cost-which-is-worth-zero">The declared cost, which is worth zero&lt;/h2>
&lt;p>The calculation has four precedence steps: cost set by a post-call hook, per-tool cost in &lt;code>tool_name_to_cost_per_query&lt;/code>, server default cost, and &lt;strong>0.0&lt;/strong>.&lt;/p>
&lt;p>Without configuring &lt;code>mcp_server_cost_info&lt;/code>, all MCP traffic is accounted at zero and consumes neither key nor team budget.&lt;/p>
&lt;p>There are exactly two Prometheus metrics for MCP:&lt;/p>
&lt;ul>
&lt;li>&lt;code>litellm_mcp_tool_calls_total&lt;/code>&lt;/li>
&lt;li>&lt;code>litellm_mcp_tool_call_spend_metric&lt;/code>&lt;/li>
&lt;/ul>
&lt;p>Both with the same eight labels: tool name, server name, key hash, key alias, team, team alias, user and end user.&lt;/p>
&lt;p>And the second is only incremented if the cost is greater than zero. With the default configuration, &lt;strong>that time series never appears in Prometheus&lt;/strong>. It is not that it is worth zero: it is that it does not exist.&lt;/p>
&lt;p>Nor is there a &lt;code>tools/list&lt;/code> metric, or MCP latency, or MCP errors, or upstream server health. For that you are left with the generic request metrics.&lt;/p>
&lt;p>In the reports, &lt;code>/spend/calculate&lt;/code> has no MCP branch: it accepts a model and messages, or a completion response, and it always calls the LLM cost calculation. The per-team report does include MCP calls, grouped by the &lt;code>model&lt;/code> column, which for these rows is worth &lt;code>MCP: tool_name&lt;/code>. That is: every tool appears as if it were a model, with zero input and output tokens. Only the sessions endpoint separates the two call types explicitly.&lt;/p>
&lt;h2 id="observability-and-the-trace-that-gets-cut">Observability, and the trace that gets cut&lt;/h2>
&lt;p>There are two OpenTelemetry stacks in LiteLLM and which one runs depends on an environment variable.&lt;/p>
&lt;p>Without &lt;code>LITELLM_OTEL_V2=true&lt;/code>, the old logger runs, and it has no MCP handling: the only appearance of MCP in the whole file is the metadata key in a list. The tool call comes out as a generic &lt;code>litellm_request&lt;/code> span with the model set to &lt;code>MCP: name&lt;/code>.&lt;/p>
&lt;p>With v2 enabled there is a dedicated span, with a dedicated role, client kind and a parent in the proxy&amp;rsquo;s request span. The span name is &lt;code>tools/call tool-name&lt;/code>. The attributes are correct and follow the conventions:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Attribute&lt;/th>
&lt;th>Value&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>&lt;code>gen_ai.operation.name&lt;/code>&lt;/td>
&lt;td>&lt;code>execute_tool&lt;/code>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>mcp.method.name&lt;/code>&lt;/td>
&lt;td>&lt;code>tools/call&lt;/code>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>mcp.session.id&lt;/code>&lt;/td>
&lt;td>MCP session identifier&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>gen_ai.tool.name&lt;/code>&lt;/td>
&lt;td>Tool name&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>gen_ai.tool.call.arguments&lt;/code>&lt;/td>
&lt;td>Only with content capture enabled&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>gen_ai.tool.call.result&lt;/code>&lt;/td>
&lt;td>Only with content capture enabled&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>server.address&lt;/code>, &lt;code>server.port&lt;/code>&lt;/td>
&lt;td>From the upstream URL, redacted&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>litellm.cost.total&lt;/code>&lt;/td>
&lt;td>The cost that is probably worth zero&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>Content capture is off by default. Without it you see neither the arguments nor the result in the trace.&lt;/p>
&lt;p>A note for anyone working with semantic conventions: &lt;code>mcp.tool.name&lt;/code> &lt;strong>does not exist&lt;/strong>. The MCP registry has exactly four attributes (&lt;code>mcp.method.name&lt;/code>, &lt;code>mcp.session.id&lt;/code>, &lt;code>mcp.protocol.version&lt;/code> and &lt;code>mcp.resource.uri&lt;/code>), all four in development, and the tool name goes in &lt;code>gen_ai.tool.name&lt;/code>. LiteLLM gets it right. And the GenAI conventions were extracted into their own repository which as of today &lt;strong>has no release at all&lt;/strong>, so there is no version number to cite.&lt;/p>
&lt;p>In Langfuse, the vendor mapper returns an empty dictionary for any span that is not an LLM call. An MCP call appears as a raw span with its attributes, with no native input, output or model fields.&lt;/p>
&lt;h3 id="the-trace-is-cut-at-the-gateway">The trace is cut at the gateway&lt;/h3>
&lt;p>Incoming context is read. The &lt;code>traceparent&lt;/code> the client puts in &lt;code>params._meta&lt;/code>, following the specification&amp;rsquo;s propagation proposal, is extracted and turned into a &lt;strong>link&lt;/strong> on the span, never into its parent. The reason is documented in the code: parenting to the remote trace would leave the span hanging off a trace whose root never reaches the backend. The client&amp;rsquo;s baggage is discarded on purpose to avoid forging identity attributes.&lt;/p>
&lt;p>Outgoing context is not propagated. There is no propagator injection call anywhere in the tree. The headers that go out towards the upstream MCP server are built with authentication and the authorised extra headers, and nothing else.&lt;/p>
&lt;p>The operational consequence: there is no end-to-end trace. If the upstream MCP server is instrumented, its spans live in another trace and there is no way to correlate them automatically.&lt;/p>
&lt;h2 id="auditing-and-the-conflict-with-privacy">Auditing and the conflict with privacy&lt;/h2>
&lt;p>A tool call leaves a normal spend row, with call type &lt;code>call_mcp_tool&lt;/code>, spend equal to the cost per query, model &lt;code>MCP: name&lt;/code>, zero tokens in the three token columns, a dedicated &lt;code>mcp_namespaced_tool_name&lt;/code> column, and the usual key, team, user and session fields.&lt;/p>
&lt;p>And in the &lt;code>metadata&lt;/code> column, in JSON, the complete structure of the call: name, &lt;strong>arguments&lt;/strong>, result, server, namespaced name, MCP session identifier, authentication mode and server resource with the URL redacted.&lt;/p>
&lt;h3 id="the-problem">The problem&lt;/h3>
&lt;p>That structure is written into the metadata column &lt;strong>without passing through the privacy switch&lt;/strong>. The assignment is direct and is not conditioned by the function that governs whether prompts and responses are stored, which does control the &lt;code>messages&lt;/code> and &lt;code>response&lt;/code> columns.&lt;/p>
&lt;p>And the redaction function does not cover it either: it only touches &lt;code>messages&lt;/code> and &lt;code>response&lt;/code>. A search for MCP in the redaction file returns nothing.&lt;/p>
&lt;p>The practical result: an operator who enables &lt;code>turn_off_message_logging&lt;/code> for GDPR keeps sending the full arguments of every tool to every logging destination they have configured. To the SIEM, to Langfuse, to S3.&lt;/p>
&lt;p>If an argument carries a national ID number, an address, a case number or a medical record, it has been exported.&lt;/p>
&lt;p>There is an aggravating factor that does not depend on LiteLLM. The revision of the specification in force introduces an extension that allows mirroring tool arguments into HTTP headers visible to load balancers, proxies and web application firewalls. The specification recommends not marking sensitive parameters that way, and it is a recommendation, and the upstream server decides.&lt;/p>
&lt;h3 id="the-fit-with-the-ens">The fit with the ENS&lt;/h3>
&lt;p>The conflict is real and has a familiar shape: activity logging (&lt;code>op.exp.8&lt;/code>) requires the call to be traced and non-repudiable; protection of personal data (&lt;code>mp.info.1&lt;/code>) requires that log not to become a secondary repository of personal data with a different purpose. The tool argument is simultaneously the evidence of the action and the data. Redacting it destroys non-repudiation; storing it in the clear creates the repository.&lt;/p>
&lt;p>The defensible way out you can build today: a hash of the argument in the audit log, the argument in the clear with encryption (&lt;code>mp.info.3&lt;/code>), separate retention and its own access control. The hash sustains non-repudiation and the clear text is only opened under procedure.&lt;/p>
&lt;p>There are two other measures this material touches directly. Change management (&lt;code>op.exp.5&lt;/code>) because registering an MCP server and, above all, changing the description of an already approved tool are changes to the security configuration. And maintenance and updates (&lt;code>op.exp.4&lt;/code>) because, as you can see in the next section, an old version of the gateway is a non-compliance with a name and a number.&lt;/p>
&lt;p>Two honesty warnings. The codes follow the numbering of Annex II of RD 311/2022 and you have to check them against the text in force before citing them in a compliance document. And on NIS2: as of the date of this post the directive &lt;strong>is still not transposed in Spain&lt;/strong>, with the draft bill on cybersecurity coordination and governance going through the process and a reasoned opinion from the Commission over the delay. What is enforceable today in the public sector and its suppliers is the ENS.&lt;/p>
&lt;h3 id="what-is-not-logged">What is not logged&lt;/h3>
&lt;p>An administrative gap: registering, modifying and deleting MCP servers &lt;strong>generates no audit log&lt;/strong>. There are three &lt;code>TODO&lt;/code> comments in the management code that say so, one of them with an &lt;code>if is_audit_logging_enabled(): pass&lt;/code>. In the same deletion block there are two more TODOs about not cleaning up orphaned permissions on keys and teams.&lt;/p>
&lt;h2 id="security">Security&lt;/h2>
&lt;h3 id="the-vulnerability-you-need-to-know-about">The vulnerability you need to know about&lt;/h3>
&lt;p>&lt;code>CVE-2026-42271&lt;/code>, published on 8 May 2026. Affects LiteLLM from 1.74.2 up to versions prior to 1.83.7. CVSS 3.1 of 8.8.&lt;/p>
&lt;p>The &lt;code>POST /mcp-rest/test/connection&lt;/code> and &lt;code>POST /mcp-rest/test/tools/list&lt;/code> endpoints, which exist to preview an MCP server before saving it, accepted the stdio transport&amp;rsquo;s &lt;code>command&lt;/code>, &lt;code>args&lt;/code> and &lt;code>env&lt;/code> in the request body. A low-privilege key executed arbitrary commands on the proxy host. It also affects Red Hat OpenShift AI on several branches.&lt;/p>
&lt;p>It is the story of this post condensed into one line: the gateway you put in to contain MCP was the hole.&lt;/p>
&lt;p>It does not come alone. The 1.83 branch concentrates three more serious ones:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>CVE&lt;/th>
&lt;th>Component&lt;/th>
&lt;th>CVSS&lt;/th>
&lt;th>Fixed in&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>&lt;code>CVE-2026-42208&lt;/code>&lt;/td>
&lt;td>SQL injection in key validation, &lt;strong>with no prior authentication&lt;/strong>&lt;/td>
&lt;td>9.8&lt;/td>
&lt;td>1.83.7&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>CVE-2026-35030&lt;/code>&lt;/td>
&lt;td>OIDC userinfo cache keyed on &lt;code>token[:20]&lt;/code>, collision and impersonation&lt;/td>
&lt;td>9.1&lt;/td>
&lt;td>1.83.0&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>CVE-2026-40217&lt;/code>&lt;/td>
&lt;td>Remote execution through bytecode rewriting in the guardrails test endpoint&lt;/td>
&lt;td>8.8&lt;/td>
&lt;td>no version recorded&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>Plus two privilege escalations fixed in 1.83.10 and 1.83.14.&lt;/p>
&lt;p>The operational conclusion is short: &lt;strong>a deployment below 1.83.14 is exposed to remote execution or to an unauthenticated SQL injection&lt;/strong>. The current branch is 1.102.0.&lt;/p>
&lt;p>And a note for context: the whole MCP ecosystem has the same pattern. The unauthenticated remote execution in MCP Inspector, the command injection in &lt;code>mcp-remote&lt;/code> when connecting to an untrusted server, DNS rebinding disabled by default in the Python and TypeScript SDKs until late 2025, the directory escapes in the reference servers. Connecting to an untrusted MCP server compromises the client, not just the other way round.&lt;/p>
&lt;h3 id="the-rug-pull-nobody-covers">The rug pull nobody covers&lt;/h3>
&lt;p>Documented since April 2025: a server can change a tool&amp;rsquo;s description &lt;strong>after&lt;/strong> the client has approved it. The description is what the model reads to decide when and how to use it, so changing it is changing the behaviour without asking for permission again.&lt;/p>
&lt;p>The specification in force does not address it. It defines list change notification and cache freshness fields, which are recency mechanisms, not integrity ones. The closest normative warning is that clients must treat tool annotations as untrusted unless they come from trusted servers.&lt;/p>
&lt;p>LiteLLM does not cover it either. The only &amp;ldquo;pinning&amp;rdquo; in the code is of the configuration&amp;rsquo;s server identifier, so that it does not change when you edit the YAML. There is no hash or comparison of the description or the input schema between successive listings. A search for related terms in the MCP module: nothing functional.&lt;/p>
&lt;p>The control exists outside the gateway. &lt;code>mcp-scan&lt;/code>, from the same researchers who documented the attack, implements description hash pinning precisely to detect this.&lt;/p>
&lt;p>In ENS terms it is an unauthorised change to the security configuration that today goes undetected. And the home-made fix fits in a cron job: store the hash of the description and schema of every approved tool, compare against &lt;code>tools/list&lt;/code> periodically, alert on the difference.&lt;/p>
&lt;h3 id="three-more-holes-in-the-code">Three more holes in the code&lt;/h3>
&lt;p>&lt;strong>No URL validation towards the upstream.&lt;/strong> A new server&amp;rsquo;s &lt;code>url&lt;/code> field has no validator. The SSRF protection utilities exist in the tree and are used in OAuth discovery and in downloading OpenAPI specifications, but &lt;strong>not&lt;/strong> in the egress of &lt;code>tools/call&lt;/code>. A server pointing at a private range or at the cloud metadata IP is accepted as is.&lt;/p>
&lt;p>&lt;strong>The stdio transport is local execution on the proxy host.&lt;/strong> There is a command allowlist: &lt;code>npx&lt;/code>, &lt;code>uvx&lt;/code>, &lt;code>python&lt;/code>, &lt;code>python3&lt;/code>, &lt;code>node&lt;/code>, &lt;code>docker&lt;/code>, &lt;code>deno&lt;/code>, extendable through an environment variable. The code&amp;rsquo;s own comment admits the residual risk: the permitted runtimes can execute code through arguments. With &lt;code>docker&lt;/code> and &lt;code>npx&lt;/code> in there, it is de facto arbitrary execution for any proxy administrator.&lt;/p>
&lt;p>&lt;strong>Two server modes skip the gateway&amp;rsquo;s authentication.&lt;/strong> Pass-through and authentication delegation to the upstream completely omit virtual key validation. They are well hardened (fail-closed if the target is mixed or unresolvable, strict comparison against truthy values, explicit blocking of the client credentials flow so as not to lend out the proxy&amp;rsquo;s service account), and the result is still that those servers have no gateway access control.&lt;/p>
&lt;h3 id="the-limit-that-truncates-in-silence">The limit that truncates in silence&lt;/h3>
&lt;p>The &lt;code>tools/list&lt;/code> pagination stops and returns what it has accumulated in three cases: repeated cursor, one thousand pages, or expiry of a global deadline. The docstring declares it: a slow or faulty upstream produces a partial catalogue instead of an error.&lt;/p>
&lt;p>The first two log a warning. The third, the deadline, exits &lt;strong>without logging anything at all&lt;/strong>. A slow server makes part of its tools disappear from the catalogue with no trace left behind.&lt;/p>
&lt;h3 id="correction-on-the-slot-leak">Correction on the slot leak&lt;/h3>
&lt;p>In the previous post I took issue 34534 at face value, according to which every MCP tool call acquires a &lt;code>max_parallel_requests&lt;/code> slot and does not release it. Tracing the 1.102.0 code, that diagnosis no longer holds: the release path exists, on both success and failure, and the limiter is registered as a litellm callback and not just as a proxy hook.&lt;/p>
&lt;p>With one honest caveat: I found no regression test covering the full cycle for MCP, and the definitive confirmation is a real proxy with &lt;code>max_parallel_requests: 2&lt;/code> and five MCP calls in a row. If anybody reproduces it today, I would like to know.&lt;/p>
&lt;h2 id="the-specification-moved-underneath">The specification moved underneath&lt;/h2>
&lt;p>The MCP revision in force is &lt;code>2026-07-28&lt;/code>, published on 28 July 2026, and in between there was another one, &lt;code>2025-11-25&lt;/code>. The changes in the latest one are not cosmetic:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Protocol sessions and the session header are removed.&lt;/strong> Cross-request state becomes an explicit identifier in the tool arguments.&lt;/li>
&lt;li>&lt;strong>The initialisation handshake is removed.&lt;/strong> Every request carries version and capabilities in &lt;code>_meta&lt;/code>. MCP becomes stateless.&lt;/li>
&lt;li>&lt;code>server/discover&lt;/code> appears as a mandatory call.&lt;/li>
&lt;li>Elicitation changes shape: out go server-initiated requests, in comes a multi-round pattern where the server returns an &amp;ldquo;input required&amp;rdquo; result and the client retries with the answers.&lt;/li>
&lt;li>&lt;code>ping&lt;/code>, &lt;code>logging/setLevel&lt;/code> and the roots change notification are removed. SSE stream resumption is removed.&lt;/li>
&lt;li>Standard HTTP headers are mandatory on POST, which lets a gateway or a web application firewall decide without parsing the JSON.&lt;/li>
&lt;li>On authorisation: mandatory issuer validation in the client, and &lt;strong>dynamic client registration is deprecated&lt;/strong> in favour of client identifier metadata documents.&lt;/li>
&lt;li>Roots, sampling and logging are deprecated, with a minimum window of twelve months.&lt;/li>
&lt;/ul>
&lt;p>LiteLLM 1.102.0 advertises &lt;code>2025-06-18&lt;/code>. Its version enumeration has three values and none of them is later than that date. The literal is sent in the &lt;code>initialize&lt;/code> against the upstream.&lt;/p>
&lt;p>One nuance that is not entirely bad: the version the gateway advertises to &lt;strong>its&lt;/strong> clients is not hard-coded in LiteLLM, the SDK negotiates it. And there is partial implementation of the intermediate revision, because the elicitation and sampling handlers reference it in their docstrings. That is: partial implementation of one revision while advertising an earlier one.&lt;/p>
&lt;p>In practical interoperability it does not hurt yet, because the client ecosystem is still mostly on the 2025 revisions. It hurts in planning: July&amp;rsquo;s stateless redesign invalidates the gateway&amp;rsquo;s session model, and that is work ahead.&lt;/p>
&lt;p>Two ecosystem notes for anyone choosing. There are clients that speak remote streamable HTTP natively (Claude and Claude Code, ChatGPT with OAuth and dynamic registration, VS Code with Copilot, Cursor, Zed, Kiro) and others that still demand stdio or a local bridge with &lt;code>mcp-remote&lt;/code>, among them Windsurf, Cline and Continue.&lt;/p>
&lt;h2 id="tool-execution-from-the-proxy-itself">Tool execution from the proxy itself&lt;/h2>
&lt;p>Besides serving MCP outwards, the proxy can consume it itself. It is enabled with an &lt;code>mcp&lt;/code> block in the body, with &lt;code>server_url&lt;/code> equal to the &lt;code>litellm_proxy&lt;/code> sentinel, and it works both on &lt;code>/v1/responses&lt;/code> and on &lt;code>/chat/completions&lt;/code>.&lt;/p>
&lt;p>The flow: it separates the gateway&amp;rsquo;s tools, lists them, filters by allowed, deduplicates, translates them into OpenAI function format and concatenates them. It calls the model. If there are tool calls and auto-execution is permitted, it runs them against the MCP servers. It builds the follow-up and makes &lt;strong>one&lt;/strong> second call.&lt;/p>
&lt;p>Two limits you need to know before designing on top of this.&lt;/p>
&lt;p>&lt;strong>The loop is a single hop.&lt;/strong> There is no multi-step iteration. The calls from the first response are executed and the follow-up is returned. A plan that chains tools requires the client to call again.&lt;/p>
&lt;p>&lt;strong>Auto-execution fails closed and as a block.&lt;/strong> It requires all MCP references to have approval set to &amp;ldquo;never&amp;rdquo;; a single one requiring approval disables auto-execution for the whole request.&lt;/p>
&lt;p>And a third one, which is the one that bites: &lt;strong>the multi-turn state of &lt;code>/v1/responses&lt;/code> is rehydrated from the spend table&lt;/strong>. The query is literal, it looks up the session of the previous &lt;code>request_id&lt;/code> and retrieves all the rows of that session ordered by time.&lt;/p>
&lt;p>With &lt;code>disable_spend_logs: true&lt;/code> nothing is written, the query comes out empty and the history is lost &lt;strong>in silence&lt;/strong>, with no 4xx error. The code itself acknowledges it: it does not expect retries because deployments that do not write spend logs have nothing to wait for. It is a coupling between a performance option and a conversation feature that nobody expects to find.&lt;/p>
&lt;h2 id="health-and-day-to-day-operation">Health and day-to-day operation&lt;/h2>
&lt;p>Health checks exist, they are on demand and they are not periodic. &lt;code>GET /v1/mcp/server/health&lt;/code> accepts repeated identifiers and returns per-server status. The implementation opens a client and runs an empty operation inside the session, with a ten-second timeout.&lt;/p>
&lt;p>It skips the check entirely if the server requires per-user authentication, and those always return unknown status.&lt;/p>
&lt;p>The surprising part: the status, last check and error columns have existed in the database since a May 2025 migration, and the check function &lt;strong>returns an object without writing to the database&lt;/strong>. Status is recalculated on every request. There is no periodic MCP health job.&lt;/p>
&lt;p>When an upstream server is down, the behaviour is to degrade and not block. The listing fans out in parallel and each failing server contributes an empty list plus a result classified into six categories. Those results reach the client in the response&amp;rsquo;s &lt;code>_meta&lt;/code> under a vendor key, without filtering the upstream&amp;rsquo;s text or URL. The reason for the design is in the code: an empty contribution with no signal makes a broken upstream indistinguishable from a healthy server with no tools.&lt;/p>
&lt;p>There is no cache of the previous list. A server that is down disappears from that turn&amp;rsquo;s tools.&lt;/p>
&lt;p>On registering and deregistering without restarting, there are two worlds. Database servers can be created, edited and deleted through the API, and every operation reloads the registry with an atomic swap. Propagation between replicas goes through Redis publication on the configuration change channel, if there is Redis, and through polling every 30 seconds if there is not.&lt;/p>
&lt;p>Servers declared in &lt;code>config.yaml&lt;/code> are loaded once, at start-up. There is no re-read. Changing them requires a restart.&lt;/p>
&lt;p>And there is a precedence trap between the two worlds, with two different warnings in the code. A database row with the same identifier as a configuration entry &lt;strong>hides the configuration one entirely&lt;/strong>. And the other way round, a configuration identifier that matches the name or alias of a database server captures its permissions, because identifiers are resolved before names. Both cases emit a log warning, once.&lt;/p>
&lt;h2 id="a-hardened-configyaml">A hardened &lt;code>config.yaml&lt;/code>&lt;/h2>
&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">general_settings&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="c"># Access is not open by default, but this closes the empty-key&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"># inheritance towards the team, which is the one that surprises people.&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">require_key_mcp_access_defined&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">require_end_user_mcp_access_defined&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="c"># Without this, allow_all_keys overrides the key&amp;#39;s explicit scope.&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">mcp_allow_all_keys_respects_mcp_scope&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>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">litellm_settings&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="c"># Does not cover MCP arguments. See the auditing section.&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">turn_off_message_logging&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">callbacks&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;langfuse_otel&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>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">mcp_servers&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">inventario&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">url&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">https://mcp-inventario.interna.svc/mcp&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">transport&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">http&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">auth_type&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">bearer_token&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">auth_value&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">os.environ/MCP_INVENTARIO_TOKEN&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"># Explicit allowlist. Also enforced on tools/call.&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">allowed_tools&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="l">buscar_articulo&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="l">consultar_stock&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"># Never true on a server with data.&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">false&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">access_groups&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;operaciones&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="nt">timeout&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">20&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">max_concurrent_requests&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">8&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">mcp_info&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">description&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;Inventario de almacen, solo lectura&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="c"># Without this, MCP spend is zero and its metric does not exist.&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">mcp_server_cost_info&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">default_cost_per_query&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">0.0005&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">tool_name_to_cost_per_query&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">consultar_stock&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">0.0002&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>And in the process environment:&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"># Without this there is no MCP span, only a generic litellm_request.&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="nv">LITELLM_OTEL_V2&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="c1"># Full block hashes, not truncated, if correlating&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># with engine events.&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="nv">LITELLM_MCP_CLIENT_TIMEOUT&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="m">45&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="nv">LITELLM_MCP_TOOL_LISTING_TIMEOUT&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="m">20&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>For clients, the rule is not to use the aggregated endpoint. Each agent points at &lt;code>/{server}/mcp&lt;/code> or at a tool set, or at the three-tool proxy mode if the catalogue is large.&lt;/p>
&lt;h2 id="checklist">Checklist&lt;/h2>
&lt;ul>
&lt;li>Check the version. Below 1.83.14 there is remote execution and an unauthenticated SQL injection with an assigned CVE.&lt;/li>
&lt;li>Inventory which servers have &lt;code>allow_all_keys&lt;/code> and enable the setting that makes it respect the key&amp;rsquo;s scope.&lt;/li>
&lt;li>Audit the access groups separately, because they are additive on top of the key and team ceiling.&lt;/li>
&lt;li>Declare in writing the fail-open behaviour on tool permission resolution errors.&lt;/li>
&lt;li>Measure the real cost: number of tools per server from the listing row, and &lt;code>tools&lt;/code> block tokens from the stored request body.&lt;/li>
&lt;li>Choose a catalogue strategy before connecting the third server: three-tool proxy mode, semantic filter, or selection by route. Not the aggregated endpoint.&lt;/li>
&lt;li>Declare &lt;code>mcp_server_cost_info&lt;/code> even with a token value, or the spend metric will not exist.&lt;/li>
&lt;li>Enable the OTel v2 logger, and decide deliberately whether to enable content capture.&lt;/li>
&lt;li>Assume the trace is cut at the gateway and plan correlation with the upstream by other means.&lt;/li>
&lt;li>Treat the spend table&amp;rsquo;s metadata column as a personal data repository: encryption, its own retention and separate access control.&lt;/li>
&lt;li>Set up the rug pull check, because nobody does it: hash of the description and schema of every approved tool, periodic comparison against the listing.&lt;/li>
&lt;li>Restrict egress towards MCP servers at the network level, because the gateway does not validate the destination URL.&lt;/li>
&lt;li>Do not enable the stdio transport unless you need it, and if you do, review what is on the command allowlist.&lt;/li>
&lt;li>If you use &lt;code>/v1/responses&lt;/code> with multi-turn state, do not enable &lt;code>disable_spend_logs&lt;/code>.&lt;/li>
&lt;/ul>
&lt;h2 id="traps-and-things-that-are-not-what-they-seem">Traps and things that are not what they seem&lt;/h2>
&lt;ul>
&lt;li>&lt;strong>MCP access is not open by default in 1.102.0.&lt;/strong> I got that wrong in the previous post. What opens things up is per-server &lt;code>allow_all_keys&lt;/code>.&lt;/li>
&lt;li>&lt;strong>&lt;code>allow_all_keys&lt;/code> overrides the key&amp;rsquo;s explicit scope&lt;/strong> unless you enable a setting that ships disabled.&lt;/li>
&lt;li>&lt;strong>&lt;code>require_key_mcp_access_defined&lt;/code> only closes key-to-team inheritance.&lt;/strong> Access group grants remain additive.&lt;/li>
&lt;li>&lt;strong>On an indeterminate failure resolving tool permissions, the code fails open&lt;/strong> for keys and JWTs.&lt;/li>
&lt;li>&lt;strong>The default cost of an MCP call is 0.0&lt;/strong>, and the spend metric is only emitted if the cost is greater than zero, so the series does not exist.&lt;/li>
&lt;li>&lt;strong>MCP calls appear in the per-team report as if they were models&lt;/strong>, named &lt;code>MCP: tool&lt;/code> and with zero tokens.&lt;/li>
&lt;li>&lt;strong>The OTel logger with MCP support is off by default.&lt;/strong> Without the environment variable there is no MCP span.&lt;/li>
&lt;li>&lt;strong>Trace context is not propagated towards the upstream MCP server.&lt;/strong> There is no end-to-end trace.&lt;/li>
&lt;li>&lt;strong>&lt;code>mcp.tool.name&lt;/code> does not exist&lt;/strong> in the semantic conventions. The name goes in &lt;code>gen_ai.tool.name&lt;/code>.&lt;/li>
&lt;li>&lt;strong>Message redaction does not cover tool arguments.&lt;/strong> They are written in the clear into the spend table.&lt;/li>
&lt;li>&lt;strong>Registering, modifying and deleting MCP servers generates no audit log.&lt;/strong> There are three TODOs in the code.&lt;/li>
&lt;li>&lt;strong>The semantic tool filter is not applied on &lt;code>tools/list&lt;/code>.&lt;/strong> Only on &lt;code>/chat/completions&lt;/code> and &lt;code>/v1/responses&lt;/code>.&lt;/li>
&lt;li>&lt;strong>The catalogue order is not deterministic&lt;/strong>, because it comes from iterating a set of strings. It breaks the client&amp;rsquo;s prompt cache even though nobody filters anything.&lt;/li>
&lt;li>&lt;strong>&lt;code>search_tools&lt;/code> in proxy mode counts keywords by default&lt;/strong>, it uses neither regular expressions nor embeddings, and its cap of five results is hard-coded.&lt;/li>
&lt;li>&lt;strong>Description overrides really do replace what the client sees&lt;/strong>, and they are the only catalogue lever that does not break the cache. They are only configurable through the database or the API.&lt;/li>
&lt;li>&lt;strong>&lt;code>spec_version&lt;/code> no longer exists&lt;/strong> in the configuration schema.&lt;/li>
&lt;li>&lt;strong>&lt;code>/mcp/sse&lt;/code> does not use the SSE transport&lt;/strong>, and the messages endpoint its object advertises is dead code.&lt;/li>
&lt;li>&lt;strong>Listing pagination truncates in silence when the deadline expires&lt;/strong>, without leaving a single log line.&lt;/li>
&lt;li>&lt;strong>A database row hides a configuration entry with the same identifier&lt;/strong>, and the other way round a configuration identifier can capture the permissions of a database server.&lt;/li>
&lt;li>&lt;strong>Servers declared in &lt;code>config.yaml&lt;/code> require a restart.&lt;/strong> Only the database ones reload hot.&lt;/li>
&lt;li>&lt;strong>The health check does not write its result to the database&lt;/strong> even though the columns exist, and there is no periodic job.&lt;/li>
&lt;li>&lt;strong>The gateway does not validate the upstream MCP server&amp;rsquo;s URL.&lt;/strong> The anti-SSRF utilities exist and are not used on that path.&lt;/li>
&lt;li>&lt;strong>&lt;code>docker&lt;/code> and &lt;code>npx&lt;/code> are on the stdio command allowlist.&lt;/strong>&lt;/li>
&lt;li>&lt;strong>The &lt;code>/v1/responses&lt;/code> tool loop is a single hop&lt;/strong>, and &lt;code>disable_spend_logs: true&lt;/code> breaks its multi-turn behaviour with no error.&lt;/li>
&lt;li>&lt;strong>LiteLLM advertises the June 2025 revision of the protocol&lt;/strong>, two behind the one in force.&lt;/li>
&lt;/ul>
&lt;h2 id="closing">Closing&lt;/h2>
&lt;p>There is a thread running through the six posts in this track that is more visible here than anywhere else. The gateway is chosen for one job and ends up doing five, and each new job arrives with its own defaults, which are almost never the ones you would have chosen.&lt;/p>
&lt;p>In the case of MCP the asymmetry is twofold. On one side, the part that looks dangerous is better built than you would assume: access control is enforced on execution and not just on the listing, the hierarchy is built out of intersections, header forwarding is an allowlist, and default access is closed, contrary to what I myself wrote two days ago. On the other, the part nobody looks at is worse: tool arguments come out in the clear bypassing the privacy switch, spend is worth zero and its metric never gets to exist, the trace is cut, and a change to the description of an approved tool goes undetected by anybody in the whole chain, neither the specification nor the gateway.&lt;/p>
&lt;p>The architecture decision that comes out of this is that the MCP door has to be treated as what it is, an interconnection of systems with third parties, and not as one more function of the LLM proxy. That means its own inventory, formal registration, change control over every tool&amp;rsquo;s description, network isolation towards the upstream and a log designed knowing that it is going to contain personal data.&lt;/p>
&lt;p>And it means accepting that the cost you really pay for connecting fifteen MCP servers is in no column of the spend table. It is spread across the &lt;code>prompt_tokens&lt;/code> of every model call, on every turn, and across the accuracy you lose when the catalogue is too large for the model to choose well. That number can be computed with what the gateway already stores. The first step is wanting to compute it.&lt;/p>
&lt;h2 id="see-also">See also&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/humans-agents-same-gateway-priority-traffic-separation/">Humans and agents on the same gateway&lt;/a> — the other face of the same problem, when the agent is a traffic class and not an identity.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/prefix-routing-what-litellm-does-not-do/">Prefix routing&lt;/a> — the sibling post, where the correction is about what the gateway does not do.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/litellm-virtual-keys-budgets-limits/">Virtual keys, budgets and limits&lt;/a> — the identity hierarchy these permissions rest on.&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 trace path that MCP spans reach, or do not reach.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/mcp-grows-up-authentication-keycloak/">When MCP grows: authentication with Keycloak&lt;/a> — the identity of your own MCP servers, upstream of this gateway.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/mcp-deep-observability-opentelemetry/">MCP from the inside and its deep observability&lt;/a> — the protocol, the six primitives and the semantic conventions.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/isolating-ai-agents-workstation-to-cluster/">The contractor with the master key: isolating AI agents&lt;/a> — the network isolation this post assumes is necessary and does not explain.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/technical-controls-ens-iso-42001-eu-ai-act-cross-mapping/">Technical controls for the ENS, 42001 and the AI Act&lt;/a> — the compliance framework that activity logging and change management fit into.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/hardening-secrets-sovereign-llm-stack-defence-in-depth/">Secret hardening in a sovereign LLM stack&lt;/a> — the upstream credentials the gateway holds.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/second-cost-vector-ai-agents-durable-execution-temporal/">The second cost vector of AI agents&lt;/a> — what a tool loop that half fails costs you.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/sizing-agents-decode-bottleneck/">Sizing for agents&lt;/a> — the other face of the tool catalogue, the one paid in proxy CPU when the token counter serialises it on every request.&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> — the seams with the identity provider and with the trace backend.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/keycloak-identity-plane-ai-platform/">Keycloak in an AI platform&lt;/a> — the two standards the MCP specification requires and the authorisation server does not implement.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/completing-keycloak-for-mcp-protected-resource/">Completing Keycloak for MCP&lt;/a> — the resource side: why the official SDKs do not validate the audience and what replaces token exchange.&lt;/li>
&lt;/ul>
&lt;h2 id="sources">Sources&lt;/h2>
&lt;ul>
&lt;li>LiteLLM, &lt;em>MCP Gateway&lt;/em>: &lt;a href="https://docs.litellm.ai/docs/mcp">https://docs.litellm.ai/docs/mcp&lt;/a>.&lt;/li>
&lt;li>LiteLLM, código: &lt;code>litellm/proxy/_experimental/mcp_server/server.py&lt;/code>, &lt;code>mcp_server_manager.py&lt;/code>, &lt;code>rest_endpoints.py&lt;/code>, &lt;code>auth/user_api_key_auth_mcp.py&lt;/code>, &lt;code>cost_calculator.py&lt;/code>, &lt;code>faults/list_outcomes.py&lt;/code>, &lt;code>litellm/proxy/management_endpoints/mcp_management_endpoints.py&lt;/code>, &lt;code>litellm/proxy/spend_tracking/spend_tracking_utils.py&lt;/code>, &lt;code>litellm/litellm_core_utils/redact_messages.py&lt;/code>, &lt;code>litellm/integrations/otel/&lt;/code>, &lt;code>litellm/responses/litellm_completion_transformation/session_handler.py&lt;/code>: &lt;a href="https://github.com/BerriAI/litellm">https://github.com/BerriAI/litellm&lt;/a>.&lt;/li>
&lt;li>LiteLLM, incidencia 34534, fuga de slot de concurrencia en llamadas MCP: &lt;a href="https://github.com/BerriAI/litellm/issues/34534">https://github.com/BerriAI/litellm/issues/34534&lt;/a>.&lt;/li>
&lt;li>NVD, &lt;code>CVE-2026-42271&lt;/code>, ejecución de comandos por los endpoints de prueba de MCP: &lt;a href="https://nvd.nist.gov/vuln/detail/CVE-2026-42271">https://nvd.nist.gov/vuln/detail/CVE-2026-42271&lt;/a>.&lt;/li>
&lt;li>NVD, &lt;code>CVE-2026-42208&lt;/code>, inyección SQL sin autenticación en la validación de clave: &lt;a href="https://nvd.nist.gov/vuln/detail/CVE-2026-42208">https://nvd.nist.gov/vuln/detail/CVE-2026-42208&lt;/a>.&lt;/li>
&lt;li>NVD, &lt;code>CVE-2026-35030&lt;/code>, colisión de clave de caché de userinfo OIDC: &lt;a href="https://nvd.nist.gov/vuln/detail/CVE-2026-35030">https://nvd.nist.gov/vuln/detail/CVE-2026-35030&lt;/a>.&lt;/li>
&lt;li>NVD, &lt;code>CVE-2025-49596&lt;/code>, ejecución remota en MCP Inspector: &lt;a href="https://nvd.nist.gov/vuln/detail/CVE-2025-49596">https://nvd.nist.gov/vuln/detail/CVE-2025-49596&lt;/a>.&lt;/li>
&lt;li>NVD, &lt;code>CVE-2025-6514&lt;/code>, inyección de comandos en &lt;code>mcp-remote&lt;/code>: &lt;a href="https://nvd.nist.gov/vuln/detail/CVE-2025-6514">https://nvd.nist.gov/vuln/detail/CVE-2025-6514&lt;/a>.&lt;/li>
&lt;li>NVD, &lt;code>CVE-2025-66416&lt;/code>, protección de DNS rebinding desactivada por defecto en el SDK de Python: &lt;a href="https://nvd.nist.gov/vuln/detail/CVE-2025-66416">https://nvd.nist.gov/vuln/detail/CVE-2025-66416&lt;/a>.&lt;/li>
&lt;li>Invariant Labs, &lt;em>MCP Security Notification: Tool Poisoning Attacks&lt;/em> (tool poisoning, rug pull, shadowing): &lt;a href="https://invariantlabs.ai/blog/mcp-security-notification-tool-poisoning-attacks">https://invariantlabs.ai/blog/mcp-security-notification-tool-poisoning-attacks&lt;/a>.&lt;/li>
&lt;li>Invariant Labs, &lt;em>Introducing mcp-scan&lt;/em> (fijado de herramientas por hash): &lt;a href="https://invariantlabs.ai/blog/introducing-mcp-scan">https://invariantlabs.ai/blog/introducing-mcp-scan&lt;/a>.&lt;/li>
&lt;li>Model Context Protocol, changelog de la revisión 2026-07-28: &lt;a href="https://modelcontextprotocol.io/specification/2026-07-28/changelog">https://modelcontextprotocol.io/specification/2026-07-28/changelog&lt;/a>.&lt;/li>
&lt;li>Model Context Protocol, changelog de la revisión 2025-11-25: &lt;a href="https://modelcontextprotocol.io/specification/2025-11-25/changelog">https://modelcontextprotocol.io/specification/2025-11-25/changelog&lt;/a>.&lt;/li>
&lt;li>Model Context Protocol, &lt;em>Security Best Practices&lt;/em> (confused deputy, indicadores de recurso, validación de audiencia): &lt;a href="https://modelcontextprotocol.io/docs/2026-07-28/tutorials/security/security_best_practices">https://modelcontextprotocol.io/docs/2026-07-28/tutorials/security/security_best_practices&lt;/a>.&lt;/li>
&lt;li>NSA, &lt;em>Model Context Protocol: Security Design Considerations for AI-Driven Automation&lt;/em>, mayo de 2026: &lt;a href="https://media.defense.gov/2026/Jun/02/2003943289/-1/-1/0/CSI_MCP_SECURITY.PDF">https://media.defense.gov/2026/Jun/02/2003943289/-1/-1/0/CSI_MCP_SECURITY.PDF&lt;/a>.&lt;/li>
&lt;li>CISA y agencias aliadas, &lt;em>Careful Adoption of Agentic AI Services&lt;/em>, mayo de 2026: &lt;a href="https://www.cisa.gov/resources-tools/resources/careful-adoption-agentic-ai-services">https://www.cisa.gov/resources-tools/resources/careful-adoption-agentic-ai-services&lt;/a>.&lt;/li>
&lt;li>Anthropic, &lt;em>Advanced tool use&lt;/em> (coste en tokens de las definiciones, búsqueda de herramientas, precisión de selección): &lt;a href="https://www.anthropic.com/engineering/advanced-tool-use">https://www.anthropic.com/engineering/advanced-tool-use&lt;/a>.&lt;/li>
&lt;li>Anthropic, &lt;em>Code execution with MCP&lt;/em>: &lt;a href="https://www.anthropic.com/engineering/code-execution-with-mcp">https://www.anthropic.com/engineering/code-execution-with-mcp&lt;/a>.&lt;/li>
&lt;li>Anthropic, &lt;em>Prompt caching&lt;/em> (jerarquía &lt;code>tools&lt;/code> → &lt;code>system&lt;/code> → &lt;code>messages&lt;/code> e invalidación): &lt;a href="https://platform.claude.com/docs/en/build-with-claude/prompt-caching">https://platform.claude.com/docs/en/build-with-claude/prompt-caching&lt;/a>.&lt;/li>
&lt;li>Anthropic, &lt;em>Tool search tool&lt;/em> (herramientas diferidas fuera del prefijo, umbral de 30 a 50 herramientas): &lt;a href="https://platform.claude.com/docs/en/agents-and-tools/tool-use/tool-search-tool">https://platform.claude.com/docs/en/agents-and-tools/tool-use/tool-search-tool&lt;/a>.&lt;/li>
&lt;li>OpenAI, &lt;em>Prompt caching&lt;/em> (el orden de las herramientas cuenta): &lt;a href="https://developers.openai.com/api/docs/guides/prompt-caching">https://developers.openai.com/api/docs/guides/prompt-caching&lt;/a>.&lt;/li>
&lt;li>OpenAI, &lt;em>Tool search&lt;/em> (las herramientas descubiertas se cargan al final del contexto): &lt;a href="https://developers.openai.com/api/docs/guides/tools-tool-search">https://developers.openai.com/api/docs/guides/tools-tool-search&lt;/a>.&lt;/li>
&lt;li>vLLM, &lt;em>Automatic Prefix Caching&lt;/em>: &lt;a href="https://docs.vllm.ai/en/latest/features/automatic_prefix_caching.html">https://docs.vllm.ai/en/latest/features/automatic_prefix_caching.html&lt;/a>.&lt;/li>
&lt;li>Microsoft, &lt;em>Use tools in chat&lt;/em> (límite de 128 herramientas por petición en VS Code): &lt;a href="https://code.visualstudio.com/docs/copilot/agents/agent-tools">https://code.visualstudio.com/docs/copilot/agents/agent-tools&lt;/a>.&lt;/li>
&lt;li>&lt;em>RAG-MCP&lt;/em>, prueba de estrés de 1 a 11.100 servidores candidatos, mayo de 2025: &lt;a href="https://arxiv.org/abs/2505.03275">https://arxiv.org/abs/2505.03275&lt;/a>.&lt;/li>
&lt;li>&lt;em>Benchmarking the Benchmarks&lt;/em>, desalineación del 18,5 % entre etiqueta y realidad, junio de 2026: &lt;a href="https://arxiv.org/html/2607.02577v1">https://arxiv.org/html/2607.02577v1&lt;/a>.&lt;/li>
&lt;li>&lt;em>MCP-Atlas&lt;/em>, taxonomía de fallos sobre 220 herramientas y veinte modelos, febrero de 2026: &lt;a href="https://arxiv.org/html/2602.00933v3">https://arxiv.org/html/2602.00933v3&lt;/a>.&lt;/li>
&lt;li>&lt;em>BiasBusters&lt;/em>, sesgo de selección entre herramientas equivalentes y peso de la descripción frente al nombre: &lt;a href="https://arxiv.org/html/2510.00307">https://arxiv.org/html/2510.00307&lt;/a>.&lt;/li>
&lt;li>&lt;em>MCP Tool Descriptions Are Smelly&lt;/em>, auditoría de 856 herramientas de 103 servidores, febrero de 2026: &lt;a href="https://arxiv.org/html/2602.14878v1">https://arxiv.org/html/2602.14878v1&lt;/a>.&lt;/li>
&lt;li>&lt;em>ToolMenuBench&lt;/em>, seis estrategias de filtrado y 26.460 ejecuciones, junio de 2026: &lt;a href="https://arxiv.org/html/2606.15508">https://arxiv.org/html/2606.15508&lt;/a>.&lt;/li>
&lt;li>&lt;em>Retrieval Models Aren&amp;rsquo;t Tool-Savvy&lt;/em> (ToolRet), corpus de 43.000 herramientas, marzo de 2025: &lt;a href="https://arxiv.org/abs/2503.01763">https://arxiv.org/abs/2503.01763&lt;/a>.&lt;/li>
&lt;li>&lt;em>Tools Are Not Islands&lt;/em>, brecha entre Recall@3 y conjunto completo, julio de 2026: &lt;a href="https://arxiv.org/html/2607.25718">https://arxiv.org/html/2607.25718&lt;/a>.&lt;/li>
&lt;li>&lt;em>ToolFlood&lt;/em>, saturación adversaria de la capa de recuperación, marzo de 2026: &lt;a href="https://arxiv.org/html/2603.13950">https://arxiv.org/html/2603.13950&lt;/a>.&lt;/li>
&lt;li>&lt;em>Scores Are Not Decisions&lt;/em>, el K óptimo como función del coste, julio de 2026: &lt;a href="https://arxiv.org/html/2607.27083v1">https://arxiv.org/html/2607.27083v1&lt;/a>.&lt;/li>
&lt;li>&lt;em>Don&amp;rsquo;t Break the Cache&lt;/em>, caché de prompts en tareas agénticas de horizonte largo, enero de 2026: &lt;a href="https://arxiv.org/html/2601.06007v2">https://arxiv.org/html/2601.06007v2&lt;/a>.&lt;/li>
&lt;li>&lt;em>How Many Tools Should an LLM Agent See? A Chance-Corrected Answer&lt;/em>, mayo de 2026: &lt;a href="https://arxiv.org/html/2605.24660v1">https://arxiv.org/html/2605.24660v1&lt;/a>.&lt;/li>
&lt;li>&lt;em>Semantic Tool Discovery for LLMs&lt;/em>, marzo de 2026: &lt;a href="https://arxiv.org/abs/2603.20313">https://arxiv.org/abs/2603.20313&lt;/a>.&lt;/li>
&lt;li>OpenTelemetry, convenciones semánticas de GenAI y MCP: &lt;a href="https://github.com/open-telemetry/semantic-conventions-genai">https://github.com/open-telemetry/semantic-conventions-genai&lt;/a>.&lt;/li>
&lt;li>Boletín Oficial del Estado, Real Decreto 311/2022, Esquema Nacional de Seguridad: &lt;a href="https://www.boe.es/buscar/act.php?id=BOE-A-2022-7191">https://www.boe.es/buscar/act.php?id=BOE-A-2022-7191&lt;/a>.&lt;/li>
&lt;li>IBM ContextForge, gateway y registro MCP: &lt;a href="https://github.com/IBM/mcp-context-forge">https://github.com/IBM/mcp-context-forge&lt;/a>.&lt;/li>
&lt;li>Docker MCP Gateway: &lt;a href="https://github.com/docker/mcp-gateway">https://github.com/docker/mcp-gateway&lt;/a>.&lt;/li>
&lt;li>agentgateway: &lt;a href="https://github.com/agentgateway/agentgateway">https://github.com/agentgateway/agentgateway&lt;/a>.&lt;/li>
&lt;/ul></description></item><item><title>LiteLLM and Langfuse: the operational pair, and the four places where the trace is lost between the gateway and the dashboard</title><link>https://blog.lo0.es/en/posts/litellm-langfuse-operational-pair/</link><pubDate>Mon, 07 Sep 2026 18:00:00 +0200</pubDate><guid>https://blog.lo0.es/en/posts/litellm-langfuse-operational-pair/</guid><description>&lt;blockquote>
&lt;p>This post opens the operational track of the control layer. The previous articles left the pieces chosen and assembled: &lt;a href="https://blog.lo0.es/en/posts/choosing-oss-gateway-llm-inference/">choosing the OSS gateway&lt;/a> and &lt;a href="https://blog.lo0.es/en/posts/llm-inference-router-l7-switchboard/">the L7 inference router&lt;/a> explain why LiteLLM ends up in front of the fleet, and &lt;a href="https://blog.lo0.es/en/posts/langfuse-inside-sorting-centre-bottleneck/">Langfuse from the inside&lt;/a> takes apart the backend that receives the traces. Here we deal with the seam between the two, which is where the work of the next three years lives.&lt;/p>
&lt;/blockquote>
&lt;h2 id="tldr">TL;DR&lt;/h2>
&lt;p>The LiteLLM plus Langfuse pair is configured with a list of callbacks and two API keys, and from there four matters decide whether the dashboard is good for operating or only for showing in a demo.&lt;/p>
&lt;p>&lt;strong>There are three integration routes and they are not equivalent.&lt;/strong> The native &lt;code>langfuse&lt;/code> callback is tied to the Python SDK v2 (&lt;code>langfuse&amp;gt;=2.59.7,&amp;lt;3.0&lt;/code> in LiteLLM&amp;rsquo;s &lt;code>pyproject.toml&lt;/code>), which writes against the legacy ingestion that Langfuse Cloud retires on &lt;strong>16 November 2026&lt;/strong>. The &lt;code>langfuse_otel&lt;/code> preset exports over OTLP and is the path recommended in the current documentation of both projects. The OTel v2 route, behind the &lt;code>LITELLM_OTEL_V2=true&lt;/code> flag, changes span names, leaves the success status at &lt;code>UNSET&lt;/code> and, above all, its Langfuse mapper does not read the request metadata: &lt;code>session_id&lt;/code>, &lt;code>trace_user_id&lt;/code>, &lt;code>tags&lt;/code> and &lt;code>trace_id&lt;/code> stop arriving.&lt;/p>
&lt;p>&lt;strong>The cost of a self-hosted model comes out as zero unless a price is registered.&lt;/strong> &lt;code>get_model_info()&lt;/code> raises an exception for a model that is not in the pricing map, &lt;code>completion_cost()&lt;/code> propagates it, the logger catches it, stores it in a debugging field and returns &lt;code>None&lt;/code>, and the payload turns it into &lt;code>0.0&lt;/code>. There is no warning at the default log level. A team&amp;rsquo;s budget is never exhausted because its spend is always zero.&lt;/p>
&lt;p>&lt;strong>Client-gateway-engine correlation does not come out of the box.&lt;/strong> LiteLLM does continue the client&amp;rsquo;s trace if a &lt;code>traceparent&lt;/code> arrives, and it also extracts from it the 32-hex trace-id to use as &lt;code>litellm_trace_id&lt;/code>, which makes the Langfuse identifier equal to the W3C one. Upstream the story changes: &lt;code>forward_traceparent_to_llm_provider&lt;/code> is &lt;code>False&lt;/code> by default, the function that applies it returns early if no &lt;code>otel&lt;/code> logger is loaded, and when it does act it forwards the client&amp;rsquo;s original header, so the vLLM span ends up a sibling of LiteLLM&amp;rsquo;s rather than a child. Add to that the fact that vLLM exports OTLP over gRPC by default and Langfuse does not accept gRPC.&lt;/p>
&lt;p>&lt;strong>Between the request and the dashboard there are four bounded queues that drop events.&lt;/strong> LiteLLM&amp;rsquo;s logging thread pool (100 threads, 10,000 pending tasks, dropping once full), the asynchronous &lt;code>LoggingWorker&lt;/code> (50,000, and once full it aggressively flushes half), OTel&amp;rsquo;s &lt;code>BatchSpanProcessor&lt;/code> (2,048 spans by default) and Langfuse&amp;rsquo;s &lt;code>ClickhouseWriter&lt;/code>, which drops rows after exhausting the retries with no dead-letter queue. None of the four applies back pressure on the request, which is exactly what you want, and none of them guarantees delivery, which is exactly what an auditor asks about.&lt;/p>
&lt;p>The rest of the article develops the four points with configuration applicable to a generic 4×H100 cluster with vLLM, the overhead figures the project itself publishes, and the honest limit of what this data sustains before ENS or ISO/IEC 42001.&lt;/p>
&lt;h2 id="you-are-here-the-seam-between-deploy-and-observe">You are here: the seam between DEPLOY and OBSERVE&lt;/h2>
&lt;p>In the &lt;a href="https://blog.lo0.es/en/posts/anatomy-on-premise-llm-inference-stack-seven-layers/">seven-layer stack&lt;/a>, LiteLLM lives in the gateway layer and Langfuse in the observability one. The separation is clean on the diagram and false in operation: the gateway is the only point in the system that sees the complete request before chopping it up and the complete response after reassembling it, so it is also the only reasonable place from which to emit the trace. The inference engine sees tokens and latencies; it does not see who is asking or how much that team has spent this month.&lt;/p>
&lt;p>That turns LiteLLM into a telemetry producer as well as a traffic distributor, and moves onto the gateway a responsibility that was not in the original design: if the callback fails, the request is served all the same and nobody notices that the dashboard has been incomplete for two days.&lt;/p>
&lt;h2 id="the-analogy-the-toll-booth-that-issues-the-ticket">The analogy: the toll booth that issues the ticket&lt;/h2>
&lt;p>A motorway toll booth does two independent jobs. It raises the barrier, which is its visible function, and it issues a ticket with number plate, time, section and amount, which is the function the concession lives off. The two share a booth and do not share a destination: the barrier responds in a hundred milliseconds because there is a car waiting, and the ticket travels to a processing centre over a slower channel that batches thousands of records before sending them.&lt;/p>
&lt;p>The design is well thought out as long as its consequence is understood. If the processing centre goes down, cars keep going through. If the ticket queue fills up, tickets are thrown away, traffic is not stopped. The concession accepts losing records rather than causing a jam, and charges on a statistical sample it knows to be imperfect.&lt;/p>
&lt;p>LiteLLM and Langfuse work the same way. The four queues in the corresponding section are the slow channel, and all of them prefer to drop rather than block. Whoever builds the pair inheriting the complete analogy gets the operation right. Whoever thinks the ticket is an accounting entry ends up explaining to an auditor why traces from a Tuesday afternoon are missing.&lt;/p>
&lt;h2 id="three-routes-and-one-with-an-expiry-date">Three routes, and one with an expiry date&lt;/h2>
&lt;p>As of September 2026 three paths coexist for getting a request from LiteLLM to Langfuse. LiteLLM&amp;rsquo;s documentation already labels the first as legacy.&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Route&lt;/th>
&lt;th>How it is enabled&lt;/th>
&lt;th>Transport&lt;/th>
&lt;th>Status&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Native &lt;code>langfuse&lt;/code> callback&lt;/td>
&lt;td>&lt;code>success_callback: [&amp;quot;langfuse&amp;quot;]&lt;/code>&lt;/td>
&lt;td>Langfuse Python SDK v2, legacy ingestion&lt;/td>
&lt;td>Maintained for compatibility. Tied to &lt;code>langfuse&amp;lt;3.0&lt;/code>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>langfuse_otel&lt;/code> preset (v1)&lt;/td>
&lt;td>&lt;code>callbacks: [&amp;quot;langfuse_otel&amp;quot;]&lt;/code>&lt;/td>
&lt;td>OTLP/HTTP against &lt;code>/api/public/otel&lt;/code>&lt;/td>
&lt;td>Recommended by both projects&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>OTel v2 route&lt;/td>
&lt;td>&lt;code>callbacks: [&amp;quot;langfuse_otel&amp;quot;]&lt;/code> + &lt;code>LITELLM_OTEL_V2=true&lt;/code>&lt;/td>
&lt;td>OTLP/HTTP, new mapper&lt;/td>
&lt;td>Opt-in. Changes semantics and loses metadata&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>The date that orders the decision is &lt;strong>16 November 2026&lt;/strong>. Langfuse&amp;rsquo;s compatibility matrix marks the legacy batch ingestion of the Python SDK v2 as retired on that date for Cloud, at which point trace ingestion through that route stops working. On self-hosted the endpoint survives, but a v4 server in &lt;code>events_only&lt;/code> mode returns 400 for every event type except &lt;code>score-create&lt;/code> and &lt;code>sdk-log&lt;/code>.&lt;/p>
&lt;p>Since LiteLLM&amp;rsquo;s native callback is pinned to &lt;code>langfuse&amp;gt;=2.59.7,&amp;lt;3.0&lt;/code> in its own &lt;code>pyproject.toml&lt;/code>, and its code calls APIs that only exist in SDK v2 (&lt;code>self.Langfuse.trace(...)&lt;/code>, &lt;code>trace.generation(...)&lt;/code>), there is no way to move up an SDK without changing route. Anyone who today has &lt;code>success_callback: [&amp;quot;langfuse&amp;quot;]&lt;/code> against Langfuse Cloud has a migration with a date on it. Anyone who has it against a self-hosted Langfuse has one with some slack, because v3 receives security patches until the end of January 2027, but they have one all the same.&lt;/p>
&lt;p>One detail of the OTLP route worth checking in older deployments: the &lt;code>x-langfuse-ingestion-version: 4&lt;/code> header is the one that enables real-time ingestion. Without it the traces arrive, but with a delay that Langfuse&amp;rsquo;s documentation puts at around ten minutes (the compatibility page says fifteen, and that discrepancy remains unresolved in their own documents). LiteLLM started sending it in version 1.95.0, of August 2026. A proxy older than that version against a Langfuse v4 produces the classic symptom: the dashboard works, but it is always behind and nobody knows why.&lt;/p>
&lt;h2 id="the-configuration-that-works-today">The configuration that works today&lt;/h2>
&lt;p>For a generic 4×H100 cluster with vLLM behind it and Langfuse self-hosted in the same Kubernetes, the OTLP route in its v1 variant is the one that gives the most functionality with the fewest surprises.&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">model_list&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">model_name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">llama-70b&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">litellm_params&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">model&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">hosted_vllm/meta-llama/Llama-3.3-70B-Instruct&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">api_base&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">http://vllm-llama70b.inference.svc.cluster.local:8000/v1&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">api_key&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">os.environ/VLLM_API_KEY&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"># Price derived from the cluster&amp;#39;s €/GPU-hour. Without this, spend = 0.0&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">input_cost_per_token&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">0.00000031&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">output_cost_per_token&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">0.00000089&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">litellm_settings&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">callbacks&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;langfuse_otel&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="nt">turn_off_message_logging&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kc">false&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c"># a conscious decision, see the PII section&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">langfuse_default_tags&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;model_group&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;user_api_key_team_alias&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;cache_hit&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>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">general_settings&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">disable_error_logs&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 class="c"># provider errors inflate SpendLogs&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">proxy_batch_write_at&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">60&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>The credentials travel through the environment. &lt;code>LANGFUSE_PUBLIC_KEY&lt;/code>, &lt;code>LANGFUSE_SECRET_KEY&lt;/code> and &lt;code>LANGFUSE_HOST&lt;/code> are enough: the preset builds the &lt;code>Authorization: Basic base64(public:secret)&lt;/code> header and adds the ingestion version one, so there is no need to touch &lt;code>OTEL_EXPORTER_OTLP_HEADERS&lt;/code> by hand. The preset&amp;rsquo;s default host points to the US region of Langfuse Cloud, a relevant detail for anyone building a platform with data sovereignty requirements: if the variable is not defined, the telemetry leaves Spain without anyone having decided it.&lt;/p>
&lt;p>For real multi-tenancy, LiteLLM supports per-team Langfuse credentials (&lt;code>POST /team/&amp;lt;id&amp;gt;/callback&lt;/code> with &lt;code>callback_vars&lt;/code>) and per-key ones (&lt;code>metadata.logging[]&lt;/code> when generating the key), and the key&amp;rsquo;s wins over the team&amp;rsquo;s without mixing. There is a limitation that breaks the architecture of anyone who planned to separate tenants across different Langfuse instances: under OTel v2, a per-key or per-team &lt;code>langfuse_host&lt;/code> does not move that tenant to another host, because the exporter fixes the destination at startup and the only thing that varies per tenant are the headers.&lt;/p>
&lt;p>And one operational warning with years of history behind it: &lt;code>MAX_LANGFUSE_INITIALIZED_CLIENTS&lt;/code> is 50 by default because each Langfuse client starts a thread. The comment in LiteLLM&amp;rsquo;s code is explicit about the incident that motivated it, with the CPU at 100% from repeated initialisations. A deployment with per-team credentials and many teams hits that ceiling.&lt;/p>
&lt;h2 id="what-is-lost-when-moving-to-otel-v2">What is lost when moving to OTel v2&lt;/h2>
&lt;p>The &lt;code>LITELLM_OTEL_V2=true&lt;/code> flag brings a step up in quality in the proxy&amp;rsquo;s general instrumentation, and at the same time a concrete regression on the path to Langfuse that the documentation acknowledges without underlining it.&lt;/p>
&lt;p>The Langfuse mapper of the v2 route emits &lt;code>langfuse.observation.type&lt;/code>, &lt;code>langfuse.observation.model.name&lt;/code>, the observation identifier, the model parameters, input and output, &lt;code>usage_details&lt;/code> and &lt;code>cost_details&lt;/code>, plus &lt;code>langfuse.trace.metadata.team_id&lt;/code> and &lt;code>team_alias&lt;/code>. It does not emit &lt;code>session.id&lt;/code>, nor &lt;code>user.id&lt;/code>, nor &lt;code>langfuse.trace.tags&lt;/code>, nor &lt;code>langfuse.trace.id&lt;/code>, nor the generation name, nor &lt;code>parent_observation_id&lt;/code>. LiteLLM&amp;rsquo;s documents put it this way: those attributes are set by the preset from the request and the response, not from metadata supplied by the client.&lt;/p>
&lt;p>The practical consequence is that an application that today groups conversations by sending &lt;code>metadata: {&amp;quot;session_id&amp;quot;: &amp;quot;...&amp;quot;, &amp;quot;trace_user_id&amp;quot;: &amp;quot;...&amp;quot;}&lt;/code> in the request body stops grouping them when the flag is enabled, with no error and no warning. The traces keep arriving, loose.&lt;/p>
&lt;p>The other v2 changes are semantic and affect alerts already written. The root span goes from being called &lt;code>Received Proxy Server Request&lt;/code> to being named after the route (&lt;code>POST /v1/chat/completions&lt;/code>), the inference span goes from &lt;code>litellm_request&lt;/code> to &lt;code>{operation} {model}&lt;/code>, the guardrail spans stop hanging off the inference span and hang off the root instead, and the success status stays at &lt;code>UNSET&lt;/code> instead of &lt;code>OK&lt;/code>. Any rule that fires on &lt;code>status == OK&lt;/code> stops firing. The way back is to remove the flag and restart.&lt;/p>
&lt;h2 id="cost-why-the-trace-says-zero">Cost: why the trace says zero&lt;/h2>
&lt;p>This is the part that takes the most time to diagnose and the least to fix.&lt;/p>
&lt;p>LiteLLM computes the cost of a request from a per-model pricing map. For commercial provider models the map comes filled in. For &lt;code>hosted_vllm/whatever&lt;/code>, which is all there is in an in-house inference factory, the map is empty, and the failure path is as follows:&lt;/p>
&lt;ol>
&lt;li>&lt;code>litellm.get_model_info()&lt;/code> raises an exception with the message &lt;code>This model isn't mapped yet&lt;/code>.&lt;/li>
&lt;li>&lt;code>completion_cost()&lt;/code> and &lt;code>response_cost_calculator()&lt;/code> propagate the exception.&lt;/li>
&lt;li>&lt;code>Logging._response_cost_calculator()&lt;/code> catches it, writes a &lt;code>StandardLoggingModelCostFailureDebugInformation&lt;/code> into &lt;code>response_cost_failure_debug_information&lt;/code>, logs it at &lt;strong>debug&lt;/strong> level and returns &lt;code>None&lt;/code>.&lt;/li>
&lt;li>The standard logging payload does &lt;code>llm_response_cost = raw_response_cost or 0.0&lt;/code>.&lt;/li>
&lt;/ol>
&lt;p>The system delivers a spend of zero, with no visible error at the default log level, and writes it into the &lt;code>spend&lt;/code> column of &lt;code>LiteLLM_SpendLogs&lt;/code>. The per-key, per-team and per-organisation budgets are evaluated against that zero and are never exhausted. The Langfuse dashboard shows a total cost of zero for everything self-hosted, which is precisely the part you wanted to measure.&lt;/p>
&lt;h3 id="registering-the-price">Registering the price&lt;/h3>
&lt;p>The pricing keys are accepted both in &lt;code>litellm_params&lt;/code> and in &lt;code>model_info&lt;/code>, and those in &lt;code>litellm_params&lt;/code> win when they are in both places. The complete set has 91 fields, but for a deployment with vLLM two are enough, and there are three things to know about them.&lt;/p>
&lt;p>The first is that &lt;strong>the price is per individual token, in USD&lt;/strong>. There are no per-thousand variants: searching for &lt;code>per_1k&lt;/code> or &lt;code>per_1000&lt;/code> in the pricing model returns nothing. A three-orders-of-magnitude error here is easy to make and hard to see, because it produces plausible figures.&lt;/p>
&lt;p>The second is that &lt;code>input_cost_per_request&lt;/code> exists in the schema and &lt;strong>is not applied&lt;/strong> in chat calls: a model registered with that field returns a cost of 0.0. To charge per request it has to be converted into a cost per token.&lt;/p>
&lt;p>The third is that &lt;code>input_cost_per_second&lt;/code> needs the response time and is computed as price times &lt;code>response_time_ms / 1000&lt;/code>, so outside the proxy&amp;rsquo;s request path it silently returns zero.&lt;/p>
&lt;p>The number that goes into those two keys comes from the article on &lt;a href="https://blog.lo0.es/en/posts/finops-multi-tenancy-gpu-cluster-who-pays/">FinOps and multi-tenancy with LiteLLM&lt;/a> and the one on &lt;a href="https://blog.lo0.es/en/posts/gpu-hour-to-cost-per-token-on-prem-vs-cloud/">cost per token and per request&lt;/a>: amortisation plus energy divided by tokens served. The example arithmetic, to set the order of magnitude: a cluster whose fully loaded cost is 9.60 USD per hour sustaining 3,000 output tokens per second produces 10.8 million tokens an hour, that is 0.00000089 USD per output token. That is the value that goes into &lt;code>output_cost_per_token&lt;/code>. If the cluster&amp;rsquo;s average utilisation is 30 % and not 100 %, the divisor changes and the price triples, which is the same lever that article develops.&lt;/p>
&lt;p>Before sending traffic, the &lt;code>/spend/calculate&lt;/code> endpoint accepts the same parameters as &lt;code>completion_cost&lt;/code> and returns the computed cost. It is the cheap way to check that the price resolves.&lt;/p>
&lt;h3 id="how-that-number-reaches-langfuse-and-who-wins">How that number reaches Langfuse, and who wins&lt;/h3>
&lt;p>The two integration routes write the cost into different fields. The native callback sends it as &lt;code>usage.total_cost&lt;/code> of the v2 schema, and duplicates it in the trace metadata as &lt;code>litellm_response_cost&lt;/code>. The OTLP preset emits &lt;code>langfuse.observation.cost_details&lt;/code> as a JSON string of the form &lt;code>{&amp;quot;total&amp;quot;: cost}&lt;/code>, and &lt;code>langfuse.observation.usage_details&lt;/code> with input, output and total.&lt;/p>
&lt;p>The rule on the Langfuse side is documented and is the right one for this case: when there is ingested cost and inferred cost, &lt;strong>the ingested one takes priority&lt;/strong>. Langfuse only computes from its own pricing table when the emitter sends no cost, and changes to a model definition apply to new generations, with no retroactive recalculation. A self-hosted model therefore needs no pricing definition in Langfuse: it is enough for LiteLLM to send &lt;code>cost_details&lt;/code>.&lt;/p>
&lt;p>One detail remains that explains a puzzling symptom. Langfuse stores the ingested cost and the computed one in separate columns, &lt;code>totalCost&lt;/code> and &lt;code>calculatedTotalCost&lt;/code>, and the session header totals add up only the first. A mixed deployment where part of the traffic brings cost and part lets Langfuse compute it shows sessions at zero while the individual generations show cost. There is an open issue about it.&lt;/p>
&lt;h3 id="the-tokens-of-a-streaming-response">The tokens of a streaming response&lt;/h3>
&lt;p>Almost all of an assistant&amp;rsquo;s traffic is streamed, and there the OpenAI &lt;code>usage&lt;/code> object only appears if the client asks for &lt;code>stream_options.include_usage&lt;/code>. LiteLLM solves this by injecting the option on its own: on the asynchronous completion routes with &lt;code>stream: true&lt;/code>, if the caller did not set it and all the candidate deployments support it, it adds it and marks &lt;code>_litellm_strip_stream_usage&lt;/code> so the usage block is used in the cost calculation and not returned to the client. The &lt;code>general_settings.always_include_stream_usage&lt;/code> flag forces or disables that behaviour, and is worth setting to &lt;code>True&lt;/code> when capability detection does not recognise the vLLM deployment.&lt;/p>
&lt;p>When the usage is missing anyway, LiteLLM reassembles the response with &lt;code>stream_chunk_builder()&lt;/code> and counts tokens with the tokeniser. It is an approximation, and it produces the second path towards a cost of zero. The two paths to zero are told apart by looking at &lt;code>total_tokens&lt;/code> in &lt;code>LiteLLM_SpendLogs&lt;/code>: zero tokens with a correct price is a usage problem, correct tokens with zero cost is a price problem.&lt;/p>
&lt;h2 id="correlation-from-the-client-to-the-engine">Correlation: from the client to the engine&lt;/h2>
&lt;p>The operational question that justifies the whole setup is being able to take a user complaint, find their request and see where the time went. That requires the trace identifier to survive three hops.&lt;/p>
&lt;div class="diagram" style="max-width:860px;margin:1.5rem auto;">
&lt;svg viewBox="0 0 860 330" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Propagation of the client's traceparent to the LiteLLM gateway and the vLLM engine, with the points where correlation breaks">
&lt;style>
.bx{fill:none;stroke:currentColor;stroke-width:1.3}
.bxd{fill:none;stroke:currentColor;stroke-width:1;stroke-dasharray:4 3}
.t{font:600 13px sans-serif;fill:currentColor}
.h{font:600 12px sans-serif;fill:currentColor}
.s{font:11px sans-serif;fill:currentColor}
.ar{stroke:currentColor;stroke-width:1.2;fill:none;marker-end:url(#ah)}
.ard{stroke:currentColor;stroke-width:1.1;fill:none;stroke-dasharray:5 3;marker-end:url(#ah)}
&lt;/style>
&lt;defs>&lt;marker id="ah" 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="currentColor"/>&lt;/marker>&lt;/defs>
&lt;text x="430" y="18" text-anchor="middle" class="t">One identifier, three hops and two places where it is lost&lt;/text>
&lt;rect x="20" y="40" width="150" height="60" class="bx" rx="6"/>
&lt;text x="36" y="62" class="h">Client&lt;/text>
&lt;text x="36" y="78" class="s">traceparent: 00-abc…-01&lt;/text>
&lt;text x="36" y="92" class="s">baggage: session.id=…&lt;/text>
&lt;rect x="240" y="40" width="180" height="60" class="bx" rx="6"/>
&lt;text x="256" y="62" class="h">LiteLLM Proxy&lt;/text>
&lt;text x="256" y="78" class="s">litellm_trace_id = abc…&lt;/text>
&lt;text x="256" y="92" class="s">gateway parent span&lt;/text>
&lt;rect x="500" y="40" width="160" height="60" class="bx" rx="6"/>
&lt;text x="516" y="62" class="h">vLLM&lt;/text>
&lt;text x="516" y="78" class="s">gen_ai.latency.*&lt;/text>
&lt;text x="516" y="92" class="s">OTLP gRPC by default&lt;/text>
&lt;rect x="700" y="40" width="140" height="60" class="bxd" rx="6"/>
&lt;text x="716" y="62" class="h">Collector&lt;/text>
&lt;text x="716" y="78" class="s">gRPC to HTTP&lt;/text>
&lt;text x="716" y="92" class="s">renames attributes&lt;/text>
&lt;path d="M170 70 L236 70" class="ar"/>
&lt;text x="203" y="62" text-anchor="middle" class="s">1&lt;/text>
&lt;path d="M420 70 L496 70" class="ard"/>
&lt;text x="458" y="62" text-anchor="middle" class="s">2&lt;/text>
&lt;path d="M660 70 L696 70" class="ar"/>
&lt;rect x="20" y="140" width="400" height="52" class="bxd" rx="6"/>
&lt;text x="36" y="160" class="h">1 · Inbound: works&lt;/text>
&lt;text x="36" y="177" class="s">LiteLLM extracts the 32-hex trace-id from the traceparent and uses it&lt;/text>
&lt;rect x="440" y="140" width="400" height="52" class="bxd" rx="6"/>
&lt;text x="456" y="160" class="h">2 · Outbound: off by default&lt;/text>
&lt;text x="456" y="177" class="s">forward_traceparent_to_llm_provider = False&lt;/text>
&lt;rect x="20" y="212" width="820" height="96" class="bx" rx="6"/>
&lt;text x="36" y="232" class="h">The three ways hop 2 fails to happen&lt;/text>
&lt;text x="36" y="252" class="s">a · The function returns early if no otel callback is loaded, even with the flag at True&lt;/text>
&lt;text x="36" y="270" class="s">b · It forwards the client header, not its own span context: vLLM ends up a sibling, not a child&lt;/text>
&lt;text x="36" y="288" class="s">c · If the client sends no traceparent, none is created&lt;/text>
&lt;path d="M220 100 L220 136" class="ar"/>
&lt;path d="M640 100 L640 136" class="ar"/>
&lt;/svg>
&lt;/div>
&lt;h3 id="what-does-work">What does work&lt;/h3>
&lt;p>If the client sends &lt;code>traceparent&lt;/code>, LiteLLM&amp;rsquo;s spans hang off the existing trace. In addition, and independently of the OTel subsystem, &lt;code>litellm_pre_call_utils.py&lt;/code> extracts by regular expression the 32-character hexadecimal trace-id and assigns it to &lt;code>data[&amp;quot;litellm_trace_id&amp;quot;]&lt;/code> as a last resort, and takes the &lt;code>session_id&lt;/code> from the W3C &lt;code>baggage&lt;/code> header. That makes the Langfuse trace identifier equal to the W3C trace identifier, which is the most robust correlation key available: with it, an identifier copied from the application log opens the trace in Langfuse with no translation.&lt;/p>
&lt;p>For the routes that are not OTel v2, moreover, headers prefixed with &lt;code>langfuse_&lt;/code> inject per-request metadata. &lt;code>langfuse_trace_id&lt;/code>, &lt;code>langfuse_trace_user_id&lt;/code> and &lt;code>langfuse_trace_metadata&lt;/code> are documented, but the code strips the prefix and writes the rest into the metadata, so any body key works as a header. Two warnings about this: header values arrive as strings and are not interpreted as JSON, and Nginx drops headers with underscores by default (&lt;code>underscores_in_headers off&lt;/code>), which produces the symptom that the metadata works locally and disappears behind the ingress.&lt;/p>
&lt;h3 id="what-does-not-work-without-effort">What does not work without effort&lt;/h3>
&lt;p>Towards the engine, propagation is off. The flag is &lt;code>litellm.forward_traceparent_to_llm_provider&lt;/code>, it is &lt;code>False&lt;/code>, and its implementation has three behaviours to know about before enabling it:&lt;/p>
&lt;ul>
&lt;li>The function returns immediately if &lt;code>open_telemetry_logger is None&lt;/code>. A deployment with &lt;code>callbacks: [&amp;quot;langfuse_otel&amp;quot;]&lt;/code> and without the generic &lt;code>otel&lt;/code> callback propagates nothing, whatever the flag is set to.&lt;/li>
&lt;li>When it does propagate, it copies the client&amp;rsquo;s &lt;code>traceparent&lt;/code> header as is, instead of injecting the context of LiteLLM&amp;rsquo;s active span. The vLLM span ends up hanging off the client&amp;rsquo;s span-id, a sibling of the gateway call&amp;rsquo;s span. The trace is one, the hierarchy is wrong.&lt;/li>
&lt;li>If the client sends no &lt;code>traceparent&lt;/code>, none is created.&lt;/li>
&lt;/ul>
&lt;p>Add to that the fact that LiteLLM&amp;rsquo;s general policy is not to forward client headers to the provider, and that the &lt;code>forward_client_headers_to_llm_api&lt;/code> allowlist covers headers prefixed with &lt;code>x-&lt;/code>, among which &lt;code>traceparent&lt;/code> is not. This is the only route.&lt;/p>
&lt;h3 id="the-vllm-side-and-why-the-collector-stops-being-optional">The vLLM side, and why the Collector stops being optional&lt;/h3>
&lt;p>vLLM enables traces with &lt;code>--otlp-traces-endpoint&lt;/code>, and &lt;code>--collect-detailed-traces&lt;/code> with &lt;code>model&lt;/code>, &lt;code>worker&lt;/code> or &lt;code>all&lt;/code> adds forward and execute times, with a performance impact its own documentation warns about. The attributes it emits are in &lt;code>vllm/tracing/utils.py&lt;/code> and are the most useful part of the setup: &lt;code>gen_ai.latency.time_in_queue&lt;/code>, &lt;code>time_to_first_token&lt;/code>, &lt;code>time_in_scheduler&lt;/code>, &lt;code>time_in_model_prefill&lt;/code>, &lt;code>time_in_model_decode&lt;/code>, &lt;code>time_in_model_forward&lt;/code>, &lt;code>time_in_model_execute&lt;/code>, &lt;code>time_in_model_inference&lt;/code> and &lt;code>e2e&lt;/code>, plus the token counters. That breakdown is what turns a &amp;ldquo;it&amp;rsquo;s slow&amp;rdquo; into &amp;ldquo;the time goes in the queue, not in decode&amp;rdquo;, which is the conversation you want to have during an incident. It fits with what was already covered in &lt;a href="https://blog.lo0.es/en/posts/instrumenting-vllm-otel-measuring-optimisations/">instrumenting vLLM with OTel&lt;/a> and with the metrics in &lt;a href="https://blog.lo0.es/en/posts/anatomy-dcgm-vllm-metrics-analogies-documented-anomalies/">DCGM and vLLM&lt;/a>.&lt;/p>
&lt;p>There are three constraints that decide the architecture:&lt;/p>
&lt;p>vLLM exports over gRPC unless &lt;code>OTEL_EXPORTER_OTLP_TRACES_PROTOCOL=http/protobuf&lt;/code> is set, and its gRPC exporter is built with &lt;code>insecure=True&lt;/code> hard-coded. Langfuse, for its part, does not support gRPC. And vLLM emits the old convention names (&lt;code>gen_ai.usage.prompt_tokens&lt;/code>, &lt;code>gen_ai.usage.completion_tokens&lt;/code>), renamed in the specification to &lt;code>input_tokens&lt;/code> and &lt;code>output_tokens&lt;/code>, plus a &lt;code>gen_ai.latency.*&lt;/code> family that was never standard and that the file itself documents as its own attributes until they are normalised.&lt;/p>
&lt;p>An OpenTelemetry Collector in between solves all three at once: it receives gRPC, exports HTTP with the Basic header towards &lt;code>/api/public/otel/v1/traces&lt;/code>, and renames attributes with a &lt;code>transform&lt;/code>. It is also the only place where tail sampling can be done, which is the next point. And add the warning from the llm-d community, which holds for any deployment with Envoy or sidecars in between: &lt;code>OTEL_SERVICE_NAME&lt;/code> has to be set per engine, because vLLM sends no service name and without it every span arrives as &lt;code>unknown_service&lt;/code>.&lt;/p>
&lt;h2 id="the-four-serial-queues">The four serial queues&lt;/h2>
&lt;p>A request&amp;rsquo;s telemetry crosses four bounded buffers before it is queryable. All four drop once full and none blocks the request.&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Queue&lt;/th>
&lt;th>Capacity&lt;/th>
&lt;th>What it does once full&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>LiteLLM logging thread pool&lt;/td>
&lt;td>100 threads, 10,000 pending tasks&lt;/td>
&lt;td>Drops the new ones with a rate-limited warning&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Asynchronous &lt;code>LoggingWorker&lt;/code>&lt;/td>
&lt;td>50,000 events, concurrency 100&lt;/td>
&lt;td>Aggressively flushes 50 % of the queue; &lt;code>enqueue()&lt;/code> documents that it never blocks&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>OTel&amp;rsquo;s &lt;code>BatchSpanProcessor&lt;/code>&lt;/td>
&lt;td>2,048 spans (&lt;code>OTEL_BSP_MAX_QUEUE_SIZE&lt;/code>), batch 512, sent every 5 s&lt;/td>
&lt;td>Drops spans silently&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Langfuse&amp;rsquo;s &lt;code>ClickhouseWriter&lt;/code>&lt;/td>
&lt;td>Bounded retries&lt;/td>
&lt;td>Drops rows after exhausting retries, with no dead-letter queue&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>The first is documented in LiteLLM&amp;rsquo;s own code with a sentence that saves half an hour of discussion: logging is best-effort and, once the cap is reached, new entries are dropped with a rate-limited warning instead of being queued forever. The third is a barely visible consequence: LiteLLM builds the &lt;code>BatchSpanProcessor&lt;/code> without adjusting &lt;code>max_queue_size&lt;/code>, so the OTel SDK defaults govern, and no LiteLLM documentation mentions the &lt;code>OTEL_BSP_*&lt;/code> variables that change them. The Langfuse SDK does the same on its own account: it sets batch size and interval, and leaves the queue size at the default.&lt;/p>
&lt;p>On top of this comes a fifth loss point that is not a queue: LiteLLM does not call &lt;code>flush()&lt;/code> per request on the native route, so a &lt;code>SIGKILL&lt;/code> on the pod takes the last batch with it. On Kubernetes, this is an argument for a generous &lt;code>terminationGracePeriodSeconds&lt;/code> and a &lt;code>preStop&lt;/code> that gives some slack.&lt;/p>
&lt;p>None of this is a defect. It is the right design decision for a piece that sits on the critical path of inference. What changes is what can be asserted with the resulting data.&lt;/p>
&lt;h2 id="what-cannot-be-promised-to-the-auditor">What cannot be promised to the auditor&lt;/h2>
&lt;p>A dashboard built on this pipeline answers statistical questions well: what is the p95 of time to first token for the model group, which team consumes the most, what proportion of requests fails, how cost per request evolves. That is what it is designed for, and that is what it is good for.&lt;/p>
&lt;p>It does not answer completeness questions well, which are the ones an ENS auditor or an ISO/IEC 42001 management system auditor asks. Faced with &amp;ldquo;show me that I have a record of every interaction with the AI system during March&amp;rdquo;, the honest answer is that the traces are a best-effort record with four documented points of silent dropping, with no dead-letter queue on the last hop and no durability guarantee on any of them.&lt;/p>
&lt;p>The clean separation is the one already proposed in &lt;a href="https://blog.lo0.es/en/posts/technical-controls-ens-iso-42001-eu-ai-act-cross-mapping/">technical controls ENS, 42001 and the AI Act&lt;/a>: the regulatory audit record is a different artefact, with a different write path, durability guarantees and retention of its own. In the pair this article deals with, the &lt;code>LiteLLM_SpendLogs&lt;/code> table in Postgres is a much better candidate than Langfuse for that function, because it is a transactional write against a relational database, and because it already contains &lt;code>request_id&lt;/code>, &lt;code>api_key&lt;/code> in hashed form, &lt;code>team_id&lt;/code>, &lt;code>organization_id&lt;/code>, &lt;code>end_user&lt;/code>, &lt;code>model&lt;/code>, tokens, &lt;code>startTime&lt;/code>, &lt;code>endTime&lt;/code>, &lt;code>status&lt;/code> and &lt;code>requester_ip_address&lt;/code>. Writing the audit event from the same place and in the same transaction as the spend is the sensible route; delegating it to the tracing pipeline is not.&lt;/p>
&lt;p>Here an operational tension appears that has to be resolved deliberately. LiteLLM&amp;rsquo;s performance recommendation for production includes &lt;code>disable_error_logs: true&lt;/code> and, in very loaded deployments, &lt;code>disable_spend_logs: true&lt;/code>, because provider errors inflate the table and concurrent writes against the same rows produce locks and exhaust the Postgres connections. Disabling &lt;code>spend_logs&lt;/code> to gain performance and at the same time leaning on that table for the audit record are incompatible decisions. Above a thousand requests per second or more than ten instances, the way out is &lt;code>use_redis_transaction_buffer: true&lt;/code>, which batches the spend updates in Redis before pushing them down to Postgres, watching the &lt;code>litellm_in_memory_spend_update_queue_size&lt;/code> and &lt;code>litellm_redis_spend_update_queue_size&lt;/code> metrics.&lt;/p>
&lt;h2 id="pii-switching-off-the-content-before-it-leaves">PII: switching off the content before it leaves&lt;/h2>
&lt;p>An LLM trace contains, by definition, the user&amp;rsquo;s prompt. In an on-premise deployment for a regulated customer, that content may be exactly what cannot leave the application&amp;rsquo;s domain, not even towards the observability backend in the same cluster.&lt;/p>
&lt;p>The controls on the proxy side, from most global to most fine-grained:&lt;/p>
&lt;ul>
&lt;li>&lt;code>litellm_settings.turn_off_message_logging: true&lt;/code> cuts messages and responses in every callback, and keeps the metadata and the spend. The redaction replaces the content with the &lt;code>REDACTED_BY_LITELLM&lt;/code> sentinel in messages, &lt;code>choices&lt;/code>, &lt;code>reasoning_content&lt;/code>, streaming deltas, tool call arguments and in the standard logging object.&lt;/li>
&lt;li>The &lt;code>x-litellm-enable-message-redaction: true&lt;/code> header enables redaction per request, and &lt;code>LiteLLM-Disable-Message-Redaction: true&lt;/code> disables it. The second is checked first and wins over everything else, including the global configuration, which makes it a control to block at the ingress if the policy is that content is never logged.&lt;/li>
&lt;li>&lt;code>redact_user_api_key_info: true&lt;/code> removes the hashed token, the &lt;code>user_id&lt;/code> and the &lt;code>team_id&lt;/code> from the log.&lt;/li>
&lt;li>Per request and per callback, &lt;code>metadata: {&amp;quot;mask_input&amp;quot;: true, &amp;quot;mask_output&amp;quot;: true}&lt;/code>.&lt;/li>
&lt;li>The body parameter &lt;code>&amp;quot;no-log&amp;quot;: true&lt;/code> disables logging of that call. It is a body parameter, not a header, and it can be disabled globally with &lt;code>global_disable_no_log_param: true&lt;/code> so a client cannot make itself invisible.&lt;/li>
&lt;/ul>
&lt;p>On the OTel v2 route the policy is inverted towards the safe side: content is off by default and is opened with &lt;code>OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT&lt;/code>, with values &lt;code>no_content&lt;/code>, &lt;code>span_only&lt;/code>, &lt;code>event_only&lt;/code> and &lt;code>span_and_event&lt;/code>. The documentation underlines that the gate is applied centrally and that a user&amp;rsquo;s request cannot force their prompt towards the backend while capture is disabled. It is also the stance the OpenTelemetry specification recommends, whose preferred pattern for production is to store the content in external storage and leave only the reference in the span.&lt;/p>
&lt;p>One specific version to check in the inventory: below LiteLLM 1.98.0, with per-team Langfuse credentials, the team&amp;rsquo;s own credentials ended up inside its traces. That version changed the origin of the metadata emitted to the &lt;code>StandardLoggingPayload&lt;/code> to cut it off. The same change removed a couple of dozen metadata fields (&lt;code>model_group&lt;/code>, &lt;code>deployment&lt;/code>, &lt;code>queue_time_seconds&lt;/code>, &lt;code>attempted_retries&lt;/code> and others), so it breaks saved dashboards and alerts that filter on them. It is an upgrade with work attached, and it has to be done anyway. The same applies to the general hardening of the stack, covered in &lt;a href="https://blog.lo0.es/en/posts/hardening-secrets-sovereign-llm-stack-defence-in-depth/">hardening and secrets of the sovereign stack&lt;/a>.&lt;/p>
&lt;h2 id="the-price-in-latency">The price in latency&lt;/h2>
&lt;p>LiteLLM publishes two overhead figures that differ by a factor of thirty, and both are theirs.&lt;/p>
&lt;p>The benchmarks page, with Locust, a thousand users with think time and a fake OpenAI endpoint, gives for four instances an &lt;code>x-litellm-overhead-duration-ms&lt;/code> of median 2 ms, p95 8 ms and p99 13 ms, at 1,170 requests per second. For two instances, median 12 ms, p95 29 ms and p99 43 ms. The page itself warns that those figures hold with around 130 in-flight requests, and that a closed-loop client with no think time keeps a thousand in flight and sees on the order of eight times that latency at the same rate, by Little&amp;rsquo;s law.&lt;/p>
&lt;p>The AIGatewayBench benchmark, of July 2026, measures the Python proxy at &lt;strong>257.7 ms of added p99&lt;/strong> with 329.5 MB of peak memory, against 0.7 ms for the Rust variant in beta. That measurement has no callbacks, no spend tracking and no persistence.&lt;/p>
&lt;p>The operational reading is that the first figure is the best case and the second the order of magnitude to plan for agentic load, which is the one that arrives in a closed loop against fast responses. To watch it there are two always-on headers: &lt;code>x-litellm-overhead-duration-ms&lt;/code> and &lt;code>x-litellm-callback-duration-ms&lt;/code>, and the documentation sets the useful threshold of the second at 100 ms, above which the diagnosis is that the payloads are too large.&lt;/p>
&lt;p>Three production settings that appear in LiteLLM&amp;rsquo;s documentation and that are wrong by default:&lt;/p>
&lt;p>&lt;strong>&lt;code>request_timeout&lt;/code> is 6,000 seconds.&lt;/strong> A hundred minutes holding a connection against a hung upstream. The recommendation is to lower it to 600.&lt;/p>
&lt;p>&lt;strong>&lt;code>LITELLM_LOG=DEBUG&lt;/code> serialises the payload with &lt;code>json.dumps(indent=4)&lt;/code> synchronously&lt;/strong>, and the documentation puts the cost of that serialisation at 2 to 5 seconds with payloads over 2 MB. It is the first cause of latency their troubleshooting guide mentions.&lt;/p>
&lt;p>&lt;strong>Background jobs register per uvicorn worker, not per pod.&lt;/strong> With &lt;code>--num_workers 4&lt;/code> and ten replicas that is forty copies of every periodic job. The &lt;code>LITELLM_JOB_ROLE&lt;/code> variable separates the pods that serve traffic from the replica that runs jobs.&lt;/p>
&lt;p>And one failure mode with an open issue that deserves to be in the runbook: under sustained 429s from the upstream, pods have been reported unable to answer readiness probes during startup, with the ensuing restart loop. It fits with what was covered in &lt;a href="https://blog.lo0.es/en/posts/incident-response-runbooks-llm-inference-kafka-keep/">incident response runbooks&lt;/a>.&lt;/p>
&lt;h2 id="start-up-checklist">Start-up checklist&lt;/h2>
&lt;p>Ten checks before signing off the installation of the pair:&lt;/p>
&lt;ol>
&lt;li>&lt;code>callbacks: [&amp;quot;langfuse_otel&amp;quot;]&lt;/code> instead of &lt;code>success_callback: [&amp;quot;langfuse&amp;quot;]&lt;/code>, unless you depend on prompt management, which the OTLP preset does not support.&lt;/li>
&lt;li>&lt;code>LANGFUSE_HOST&lt;/code> defined explicitly. The preset&amp;rsquo;s default value points to the US region.&lt;/li>
&lt;li>LiteLLM at 1.98.0 or higher, because of the team credential leak into the traces, and at 1.95.0 or higher because of the v4 ingestion header.&lt;/li>
&lt;li>A price registered for every self-hosted model, per token and in USD, verified with &lt;code>/spend/calculate&lt;/code> before opening traffic.&lt;/li>
&lt;li>A test request with a known &lt;code>traceparent&lt;/code>, checking that the same identifier appears in Langfuse.&lt;/li>
&lt;li>An OpenTelemetry Collector between vLLM and Langfuse, with &lt;code>OTEL_SERVICE_NAME&lt;/code> per engine and renaming of the old token attributes.&lt;/li>
&lt;li>&lt;code>request_timeout: 600&lt;/code> and &lt;code>LITELLM_LOG&lt;/code> out of &lt;code>DEBUG&lt;/code>.&lt;/li>
&lt;li>Tail sampling in the Collector, with a latency and status code policy, to keep 100 % of what is slow and what failed.&lt;/li>
&lt;li>A written decision about prompt content, and blocking of the &lt;code>LiteLLM-Disable-Message-Redaction&lt;/code> header at the ingress if the policy is not to log.&lt;/li>
&lt;li>A dashboard with &lt;code>x-litellm-overhead-duration-ms&lt;/code>, &lt;code>x-litellm-callback-duration-ms&lt;/code> and Langfuse&amp;rsquo;s ingestion queue depth (&lt;code>langfuse.queue.ingestion.depth&lt;/code>), which is the autoscaling signal for its workers.&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>The trace arrives, but ten minutes late.&lt;/strong> The &lt;code>x-langfuse-ingestion-version: 4&lt;/code> header is missing, which LiteLLM sends from 1.95.0. It is not a ClickHouse capacity problem.&lt;/p>
&lt;p>&lt;strong>The metadata works in development and disappears in production.&lt;/strong> Nginx drops headers with underscores by default, and all of this integration&amp;rsquo;s have one.&lt;/p>
&lt;p>&lt;strong>Session cost comes out as zero and generation cost does not.&lt;/strong> Langfuse adds up only the ingested cost in the session totals, not the one it computes itself.&lt;/p>
&lt;p>&lt;strong>Enabling OTel v2 breaks nothing visible and ungroups the conversations.&lt;/strong> The v2 mapper does not read &lt;code>session_id&lt;/code> or &lt;code>trace_user_id&lt;/code> from the request.&lt;/p>
&lt;p>&lt;strong>A team&amp;rsquo;s budget is never exhausted.&lt;/strong> Its spend is zero because the model has no price, and the calculation failure is only logged at debug level.&lt;/p>
&lt;p>&lt;strong>The alerts stop firing after the migration to v2.&lt;/strong> The success status becomes &lt;code>UNSET&lt;/code>; no rule on &lt;code>OK&lt;/code> is satisfied.&lt;/p>
&lt;p>&lt;strong>Forwarding the &lt;code>traceparent&lt;/code> is enabled and it does not reach the engine.&lt;/strong> The generic &lt;code>otel&lt;/code> callback is missing, without which the function that applies it returns before looking at the flag.&lt;/p>
&lt;p>&lt;strong>All the engines&amp;rsquo; spans appear under &lt;code>unknown_service&lt;/code>.&lt;/strong> vLLM emits no service name; &lt;code>OTEL_SERVICE_NAME&lt;/code> has to be set per deployment.&lt;/p>
&lt;h2 id="closing">Closing&lt;/h2>
&lt;p>The gateway and the tracing backend are the two pieces of the inference stack that take the least time to install and the longest to operate well. The installation fits in a ConfigMap. The operation consists of four decisions no getting-started guide raises: which integration route to take knowing that one expires in November, where the price per token of a model nobody invoices comes from, what to do with the &lt;code>traceparent&lt;/code> at each hop, and what to tell an auditor about data the system drops by design when things get tight.&lt;/p>
&lt;p>The answer to the fourth is the one that orders the other three. This pipeline is operational instrumentation, with statistical precision and best-effort guarantees, and separating it from day one from the regulatory audit record avoids the awkward conversation two years from now. With that separation made, the LiteLLM and Langfuse pair is the pair of pieces that turns a fleet of GPUs into a platform with an owner, a known cost and explainable latency.&lt;/p>
&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: the sorting centre&lt;/a> — the six-service architecture and the ten backend knobs behind the receiving end of this integration. That post opens the box; this one connects the cable.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/llm-inference-router-l7-switchboard/">The LLM inference router: the L7 switchboard&lt;/a> — the router&amp;rsquo;s four functions and the minimal LiteLLM Proxy manifest on the generic cluster.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/choosing-oss-gateway-llm-inference/">Choosing the OSS gateway for LLM inference&lt;/a> — the decision prior to this article, with the small print on licences and what is gated in LiteLLM.&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> — where the numbers that go into &lt;code>input_cost_per_token&lt;/code> and &lt;code>output_cost_per_token&lt;/code> come from, and why utilisation is the lever.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/gpu-hour-to-cost-per-token-on-prem-vs-cloud/">From GPU-hour to cost per token and per request&lt;/a> — the full arithmetic of the conversion summarised here in one line.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/instrumenting-vllm-otel-measuring-optimisations/">Instrumenting vLLM with OpenTelemetry&lt;/a> — the other end of the correlation, with the detail of what each &lt;code>gen_ai.latency.*&lt;/code> measures.&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 &lt;code>SDK → Collector → backend&lt;/code> pipeline and the two-layer sampling this post takes as given.&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 the regulatory audit record cannot lean on a best-effort pipeline.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/incident-response-runbooks-llm-inference-kafka-keep/">Incident response runbooks&lt;/a> — where the diagnostic headers and the readiness probe failure mode under sustained 429s fit.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/anatomy-llm-request-production-tour-six-stages/">Anatomy of an LLM request in production&lt;/a> — the complete journey of a request through every layer, of which this post details two.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/litellm-day-2-high-availability/">LiteLLM on day 2: high availability&lt;/a> — the process model, the probes, the retries and the 6,000-second timeout of the proxy that emits these traces.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/litellm-virtual-keys-budgets-limits/">Virtual keys, budgets and limits&lt;/a> — who can call and with what cap, and the audit table that does work as evidence when the traces do not.&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> — the gateway&amp;rsquo;s other four seams, including the identity that never reaches the trace.&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> — the new data model at the receiving end, and the 16 November date with its small print.&lt;/li>
&lt;/ul>
&lt;h2 id="sources">Sources&lt;/h2>
&lt;ul>
&lt;li>LiteLLM, &lt;em>Langfuse integration&lt;/em> (rutas de integración, metadata aceptada, cabeceras &lt;code>langfuse_*&lt;/code>): &lt;a href="https://docs.litellm.ai/docs/observability/langfuse_integration">https://docs.litellm.ai/docs/observability/langfuse_integration&lt;/a>.&lt;/li>
&lt;li>LiteLLM, &lt;em>OpenTelemetry v2&lt;/em> y &lt;em>Migración a OTel v2&lt;/em> (preset, captura de contenido, cambios de semántica): &lt;a href="https://docs.litellm.ai/docs/observability/opentelemetry_v2">https://docs.litellm.ai/docs/observability/opentelemetry_v2&lt;/a> · &lt;a href="https://docs.litellm.ai/docs/observability/opentelemetry_v2_migration">https://docs.litellm.ai/docs/observability/opentelemetry_v2_migration&lt;/a>.&lt;/li>
&lt;li>LiteLLM, &lt;em>Logging&lt;/em> y &lt;em>Config settings&lt;/em> (redacción, &lt;code>turn_off_message_logging&lt;/code>, variables de entorno): &lt;a href="https://docs.litellm.ai/docs/proxy/logging">https://docs.litellm.ai/docs/proxy/logging&lt;/a> · &lt;a href="https://docs.litellm.ai/docs/proxy/config_settings">https://docs.litellm.ai/docs/proxy/config_settings&lt;/a>.&lt;/li>
&lt;li>LiteLLM, &lt;em>Custom pricing&lt;/em> y &lt;em>Spend tracking&lt;/em> (claves de precio, SpendLogs, endpoints de gasto): &lt;a href="https://docs.litellm.ai/docs/proxy/custom_pricing">https://docs.litellm.ai/docs/proxy/custom_pricing&lt;/a> · &lt;a href="https://docs.litellm.ai/docs/proxy/cost_tracking">https://docs.litellm.ai/docs/proxy/cost_tracking&lt;/a>.&lt;/li>
&lt;li>LiteLLM, &lt;em>Production best practices&lt;/em> (topología, &lt;code>request_timeout&lt;/code>, buffer de transacciones en Redis, &lt;code>LITELLM_JOB_ROLE&lt;/code>): &lt;a href="https://docs.litellm.ai/docs/proxy/prod">https://docs.litellm.ai/docs/proxy/prod&lt;/a>.&lt;/li>
&lt;li>LiteLLM, &lt;em>Benchmarks&lt;/em> y &lt;em>Latency overhead&lt;/em> (cifras de sobrecarga y cabeceras de diagnóstico): &lt;a href="https://docs.litellm.ai/docs/benchmarks">https://docs.litellm.ai/docs/benchmarks&lt;/a> · &lt;a href="https://docs.litellm.ai/docs/troubleshoot/latency_overhead">https://docs.litellm.ai/docs/troubleshoot/latency_overhead&lt;/a>.&lt;/li>
&lt;li>LiteLLM, &lt;em>Rust AI Gateway benchmarks&lt;/em> (la cifra de 257,7 ms de p99 del proxy Python): &lt;a href="https://docs.litellm.ai/blog/rust-ai-gateway-benchmarks">https://docs.litellm.ai/blog/rust-ai-gateway-benchmarks&lt;/a>.&lt;/li>
&lt;li>LiteLLM, notas de versión v1.95.0, v1.98.0 y v1.100.0: &lt;a href="https://docs.litellm.ai/release_notes/v1.95.0/v1-95-0">https://docs.litellm.ai/release_notes/v1.95.0/v1-95-0&lt;/a> · &lt;a href="https://docs.litellm.ai/release_notes/v1.98.0/v1-98-0">https://docs.litellm.ai/release_notes/v1.98.0/v1-98-0&lt;/a> · &lt;a href="https://docs.litellm.ai/release_notes/v1.100.0/v1-100-0">https://docs.litellm.ai/release_notes/v1.100.0/v1-100-0&lt;/a>.&lt;/li>
&lt;li>Langfuse, &lt;em>OpenTelemetry&lt;/em> (endpoint, autenticación, tabla de mapeo de atributos, ausencia de gRPC): &lt;a href="https://langfuse.com/integrations/native/opentelemetry">https://langfuse.com/integrations/native/opentelemetry&lt;/a>.&lt;/li>
&lt;li>Langfuse, &lt;em>Compatibility&lt;/em> y &lt;em>Migrate v3 to v4&lt;/em> (la fecha del 16 de noviembre de 2026 y los modos de escritura): &lt;a href="https://langfuse.com/docs/compatibility">https://langfuse.com/docs/compatibility&lt;/a> · &lt;a href="https://langfuse.com/self-hosting/upgrade/upgrade-guides/upgrade-v3-to-v4">https://langfuse.com/self-hosting/upgrade/upgrade-guides/upgrade-v3-to-v4&lt;/a>.&lt;/li>
&lt;li>Langfuse, &lt;em>Token and cost tracking&lt;/em> (prioridad del coste ingerido sobre el inferido): &lt;a href="https://langfuse.com/docs/observability/features/token-and-cost-tracking">https://langfuse.com/docs/observability/features/token-and-cost-tracking&lt;/a>.&lt;/li>
&lt;li>Langfuse, &lt;em>Scaling&lt;/em> y &lt;em>Sampling&lt;/em> (dimensionado, señal de autoescalado, muestreo de cabecera): &lt;a href="https://langfuse.com/self-hosting/configuration/scaling">https://langfuse.com/self-hosting/configuration/scaling&lt;/a> · &lt;a href="https://langfuse.com/docs/observability/features/sampling">https://langfuse.com/docs/observability/features/sampling&lt;/a>.&lt;/li>
&lt;li>Langfuse, issue #13468 — descarte de filas en &lt;code>ClickhouseWriter&lt;/code> sin cola de mensajes muertos: &lt;a href="https://github.com/langfuse/langfuse/issues/13468">https://github.com/langfuse/langfuse/issues/13468&lt;/a>. Issue #15109 — coste de sesión a cero: &lt;a href="https://github.com/langfuse/langfuse/issues/15109">https://github.com/langfuse/langfuse/issues/15109&lt;/a>. Issue #5173 — Nginx y cabeceras con guión bajo: &lt;a href="https://github.com/langfuse/langfuse/issues/5173">https://github.com/langfuse/langfuse/issues/5173&lt;/a>.&lt;/li>
&lt;li>vLLM, &lt;code>vllm/tracing/utils.py&lt;/code> y &lt;code>vllm/tracing/otel.py&lt;/code> en la etiqueta v0.28.0 (atributos emitidos, protocolo por defecto, &lt;code>insecure=True&lt;/code>): &lt;a href="https://github.com/vllm-project/vllm/blob/v0.28.0/vllm/tracing/utils.py">https://github.com/vllm-project/vllm/blob/v0.28.0/vllm/tracing/utils.py&lt;/a>.&lt;/li>
&lt;li>Langfuse, &lt;em>vLLM integration&lt;/em> (la advertencia de que vLLM solo exporta contadores y latencias): &lt;a href="https://langfuse.com/integrations/model-providers/vllm">https://langfuse.com/integrations/model-providers/vllm&lt;/a>.&lt;/li>
&lt;li>LiteLLM, &lt;code>litellm/constants.py&lt;/code> y &lt;code>litellm/litellm_core_utils/logging_worker.py&lt;/code> (capacidades de las colas y política de descarte): &lt;a href="https://github.com/BerriAI/litellm/blob/main/litellm/constants.py">https://github.com/BerriAI/litellm/blob/main/litellm/constants.py&lt;/a>.&lt;/li>
&lt;li>OpenTelemetry, &lt;em>Semantic Conventions for Generative AI&lt;/em> y el repositorio &lt;code>semantic-conventions-genai&lt;/code> (estado de desarrollo, renombrados, captura de contenido): &lt;a href="https://github.com/open-telemetry/semantic-conventions-genai">https://github.com/open-telemetry/semantic-conventions-genai&lt;/a>.&lt;/li>
&lt;li>OpenTelemetry Collector, &lt;code>tailsamplingprocessor&lt;/code> (políticas de muestreo por cola y la restricción de instancia única por traza): &lt;a href="https://github.com/open-telemetry/opentelemetry-collector-contrib/blob/main/processor/tailsamplingprocessor/README.md">https://github.com/open-telemetry/opentelemetry-collector-contrib/blob/main/processor/tailsamplingprocessor/README.md&lt;/a>.&lt;/li>
&lt;li>llm-d, &lt;em>End-to-end and fine-grained tracing in llm-d&lt;/em> (&lt;code>OTEL_SERVICE_NAME&lt;/code> y los spans huérfanos tras Envoy): &lt;a href="https://llm-d.ai/blog/end-to-end-and-fine-grained-tracing-in-llm-d">https://llm-d.ai/blog/end-to-end-and-fine-grained-tracing-in-llm-d&lt;/a>.&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><item><title>Instrumenting vLLM with OTel: measuring what the optimisations really do</title><link>https://blog.lo0.es/en/posts/instrumenting-vllm-otel-measuring-optimisations/</link><pubDate>Fri, 05 Jun 2026 04:00:00 +0000</pubDate><guid>https://blog.lo0.es/en/posts/instrumenting-vllm-otel-measuring-optimisations/</guid><description>&lt;h2 id="tldr">TL;DR&lt;/h2>
&lt;p>vLLM exposes two independent observability signals: Prometheus metrics (pull, aggregated) and OTel traces (push, per request). To measure whether chunked prefill, prefix caching, speculative decoding, FP8 KV cache and concurrency are really working, you need both. The metrics tell you what is happening in the system; the traces tell you why one specific request was slow. This article configures the complete pipeline and maps each optimisation to its diagnostic metric.&lt;/p>
&lt;hr>
&lt;h2 id="the-analogy">The analogy&lt;/h2>
&lt;p>A Formula 1 driver and his telemetry engineers. The driver feels that the car &amp;ldquo;goes odd&amp;rdquo; in turn 3, but without the sensor data he does not know whether it is the rear tyre, the differential or the fuel. The engineers see exactly what happened in that turn, temperature per sensor, lateral load per millisecond.&lt;/p>
&lt;p>vLLM without OTel is the driver on his own: you notice that the TTFT &amp;ldquo;looks high&amp;rdquo; but you do not know whether it is a long prefill, a prefix cache miss, or a KV cache preemption. With OTel you have the full picture: the metrics are the race summary (aggregated), the traces are the lap-by-lap telemetry (per-request).&lt;/p>
&lt;hr>
&lt;h2 id="architecture-of-the-two-signals">Architecture of the two signals&lt;/h2>
&lt;p>vLLM deliberately separates its two observability channels:&lt;/p>
&lt;pre tabindex="0">&lt;code> ┌─────────────────────────────┐
│ vLLM │
│ │
requests ────────────►│ SchedulerStats │
│ │ │
│ ├─► Prometheus /metrics │◄── scrape (pull)
│ │ (aggregated, ~15s) │
│ │ │
│ └─► OTLP exporter │──► push (per request)
│ (spans, immediate) │
└─────────────────────────────┘
│ OTLP gRPC/HTTP
▼
┌─────────────────────┐
│ OTel Collector │
│ │
│ receivers: │
│ otlp (traces) │
│ prometheus │
│ exporters: │
│ langfuse │
│ prometheus remote│
│ loki (logs) │
└─────────────────────┘
&lt;/code>&lt;/pre>&lt;p>&lt;strong>Prometheus pull&lt;/strong> exposes metrics with the &lt;code>vllm:&lt;/code> prefix at &lt;code>:8000/metrics&lt;/code>. They are histograms, gauges and counters updated on every scheduler iteration. Good for dashboards and alerts about the system as a whole.&lt;/p>
&lt;p>&lt;strong>OTLP push&lt;/strong> sends one span per request, immediately on completion. It contains attributes of the specific request: prompt tokens, generated tokens, TTFT, model. Good for debugging anomalous requests and for Langfuse.&lt;/p>
&lt;hr>
&lt;h2 id="installation-and-basic-configuration">Installation and basic configuration&lt;/h2>
&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"># vLLM with OTel support&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">pip install &lt;span class="s2">&amp;#34;vllm[otel]&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># Installs: opentelemetry-sdk, opentelemetry-api,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># opentelemetry-exporter-otlp, opentelemetry-semantic-conventions-ai&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-bash" data-lang="bash">&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># Start vLLM with OTel enabled&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="nb">export&lt;/span> &lt;span class="nv">OTEL_SERVICE_NAME&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;vllm-production&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="nb">export&lt;/span> &lt;span class="nv">OTEL_EXPORTER_OTLP_TRACES_ENDPOINT&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;http://otel-collector:4317&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="nb">export&lt;/span> &lt;span class="nv">OTEL_EXPORTER_OTLP_TRACES_PROTOCOL&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;grpc&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="nb">export&lt;/span> &lt;span class="nv">OTEL_EXPORTER_OTLP_TRACES_INSECURE&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;true&amp;#34;&lt;/span> &lt;span class="c1"># on an internal network without TLS&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">vllm serve Qwen/Qwen2.5-7B-Instruct &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> --otlp-traces-endpoint http://otel-collector:4317 &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> --enable-chunked-prefill &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> --enable-prefix-caching &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> --kv-cache-dtype fp8 &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> --speculative-model Qwen/Qwen2.5-0.5B-Instruct &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> --num-speculative-tokens &lt;span class="m">5&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>The Prometheus metrics need no extra configuration: they are always at &lt;code>:8000/metrics&lt;/code>.&lt;/p>
&lt;hr>
&lt;h2 id="otel-collector-minimum-configuration">OTel Collector: minimum configuration&lt;/h2>
&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"># otel-collector-config.yaml&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">receivers&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">otlp&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">protocols&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">grpc&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="m">0.0.0.0&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="m">4317&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">http&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="m">0.0.0.0&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="m">4318&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">prometheus&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">config&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">scrape_configs&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">job_name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">vllm&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">scrape_interval&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">15s&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">static_configs&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">targets&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;vllm:8000&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>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&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">batch&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">timeout&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">5s&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">resource&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">attributes&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">key&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">deployment.environment&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">value&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;production&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">action&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">upsert&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="s2">&amp;#34;https://cloud.langfuse.com/api/public/otel&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">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 &amp;lt;base64(pk:sk)&amp;gt;&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>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">prometheusremotewrite&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="s2">&amp;#34;http://prometheus:9090/api/v1/write&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>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">service&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">pipelines&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">traces&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">receivers&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="l">otlp]&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">processors&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="l">batch, resource]&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">exporters&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="l">otlphttp/langfuse]&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">metrics&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">receivers&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">[&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">processors&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="l">batch]&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">exporters&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="l">prometheusremotewrite]&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;hr>
&lt;h2 id="the-five-metrics-that-matter">The five metrics that matter&lt;/h2>
&lt;p>Every optimisation has a primary diagnostic signal. If the metric does not move as expected after enabling the flag, there is a configuration or load problem.&lt;/p>
&lt;h3 id="1-chunked-prefill--vllmtime_to_first_token_seconds">1. Chunked prefill → &lt;code>vllm:time_to_first_token_seconds&lt;/code>&lt;/h3>
&lt;p>Chunked prefill should reduce the variance of the TTFT, not necessarily the median. Its main goal is for the high percentiles (p99) to come down even if the p50 rises slightly.&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-promql" data-lang="promql">&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># TTFT p50 and p99 — expect p99 to drop with chunked prefill enabled&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="kr">histogram_quantile&lt;/span>&lt;span class="o">(&lt;/span>&lt;span class="mf">0.50&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kr">rate&lt;/span>&lt;span class="o">(&lt;/span>&lt;span class="nv">vllm&lt;/span>&lt;span class="err">:&lt;/span>&lt;span class="nv">time_to_first_token_seconds_bucket&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s">5m&lt;/span>&lt;span class="p">]&lt;/span>&lt;span class="o">))&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="kr">histogram_quantile&lt;/span>&lt;span class="o">(&lt;/span>&lt;span class="mf">0.99&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kr">rate&lt;/span>&lt;span class="o">(&lt;/span>&lt;span class="nv">vllm&lt;/span>&lt;span class="err">:&lt;/span>&lt;span class="nv">time_to_first_token_seconds_bucket&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s">5m&lt;/span>&lt;span class="p">]&lt;/span>&lt;span class="o">))&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;strong>Sign that it works:&lt;/strong> the p99/p50 ratio approaches 1. Without chunked prefill, one request&amp;rsquo;s long prefill blocks all the others and the p99 rises disproportionately.&lt;/p>
&lt;p>&lt;strong>Sign of a problem:&lt;/strong> p50 and p99 both rise. A &lt;code>--max-num-batched-tokens&lt;/code> that is too low makes the chunks so small that the prefill takes many steps to complete even though the other requests are not blocked. Raise the budget.&lt;/p>
&lt;p>It is also useful to look at the &lt;code>llm.usage.prompt_tokens&lt;/code> attribute per span in the OTel traces: requests with many prompt tokens should have a proportional TTFT, not a blocking one.&lt;/p>
&lt;hr>
&lt;h3 id="2-prefix-caching--vllmgpu_prefix_cache_hit_rate">2. Prefix caching → &lt;code>vllm:gpu_prefix_cache_hit_rate&lt;/code>&lt;/h3>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-promql" data-lang="promql">&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># Prefix cache hit rate on the GPU (0.0–1.0)&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="nv">vllm&lt;/span>&lt;span class="err">:&lt;/span>&lt;span class="nv">gpu_prefix_cache_hit_rate&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="c1"># Evolution over a 5-minute window&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="kr">rate&lt;/span>&lt;span class="o">(&lt;/span>&lt;span class="nv">vllm&lt;/span>&lt;span class="err">:&lt;/span>&lt;span class="nv">gpu_prefix_cache_hit_rate&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s">5m&lt;/span>&lt;span class="p">]&lt;/span>&lt;span class="o">)&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;strong>Sign that it works:&lt;/strong> a sustained hit rate &amp;gt; 0.5 in workloads with a shared system prompt. With a hit rate of 0.8, 80% of requests skip the prefix prefill; the TTFT of those requests falls to the cost of the variable suffix alone.&lt;/p>
&lt;p>&lt;strong>Sign of a problem: a hit rate close to zero&lt;/strong> despite system prompts that &amp;ldquo;look&amp;rdquo; identical. The usual causes:&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"># ❌ This breaks the prefix caching hash:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">system_prompt&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="sa">f&lt;/span>&lt;span class="s2">&amp;#34;Today is &lt;/span>&lt;span class="si">{&lt;/span>&lt;span class="n">datetime&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">now&lt;/span>&lt;span class="p">()&lt;/span>&lt;span class="si">}&lt;/span>&lt;span class="s2">. You are an assistant...&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># ^^^ a different timestamp on every request&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 system prompt has to be identical byte for byte:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">system_prompt&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s2">&amp;#34;You are an assistant specialising in infrastructure...&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Any variation in the system prompt, timestamps, session IDs, interpolated prompt versions, produces a different hash and a cache miss. The OTel traces do not expose the hit/miss per request directly in the current implementation; use them to correlate a high &lt;code>llm.usage.prompt_tokens&lt;/code> with a high TTFT in the same request.&lt;/p>
&lt;hr>
&lt;h3 id="3-speculative-decoding--vllmspec_decode_draft_acceptance_rate">3. Speculative decoding → &lt;code>vllm:spec_decode_draft_acceptance_rate&lt;/code>&lt;/h3>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-promql" data-lang="promql">&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># Acceptance rate of the draft model (0.0–1.0)&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="nv">vllm&lt;/span>&lt;span class="err">:&lt;/span>&lt;span class="nv">spec_decode_draft_acceptance_rate&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="c1"># Estimated effective speedup (with k=5 proposed tokens)&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"># speedup ≈ (1 + α·k) / (1 + overhead_draft)&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"># Simplified: if α=0.75 and k=5 → speedup ≈ 1 + 0.75×5×(1 - cost_ratio) &lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;strong>Sign that it works:&lt;/strong> a sustained acceptance rate &amp;gt; 0.70. Below 0.60, the draft model&amp;rsquo;s overhead outweighs the gain from the accepted tokens and speculative decoding is counterproductive.&lt;/p>
&lt;p>&lt;strong>Sign of a problem:&lt;/strong> an acceptance rate &amp;lt; 0.50. The usual causes:&lt;/p>
&lt;ul>
&lt;li>A drafter from a different family than the verifier (for instance, Mistral 0.5B as the draft for Qwen 7B).&lt;/li>
&lt;li>A high generation temperature (&amp;gt;0.9): the higher the temperature, the more the draft&amp;rsquo;s distribution diverges from the verifier&amp;rsquo;s.&lt;/li>
&lt;li>A very large batch: at high concurrency, the draft can fall outside the domain of the current requests.&lt;/li>
&lt;/ul>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-promql" data-lang="promql">&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># Alert: inefficient speculative decoding&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="nv">ALERT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nv">SpecDecodeInefficient&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="nv">IF&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nv">vllm&lt;/span>&lt;span class="err">:&lt;/span>&lt;span class="nv">spec_decode_draft_acceptance_rate&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">&amp;lt;&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="mf">0.60&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="nv">FOR&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s">5m&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="nv">LABELS&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">{&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nl">severity&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">&amp;#34;&lt;/span>&lt;span class="s">warning&lt;/span>&lt;span class="p">&amp;#34;&lt;/span>&lt;span class="w"> &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="nv">ANNOTATIONS&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">{&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nl">summary&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">&amp;#34;&lt;/span>&lt;span class="s">Draft acceptance rate low: disable spec decode or change the drafter&lt;/span>&lt;span class="p">&amp;#34;&lt;/span>&lt;span class="w"> &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>In the OTel traces, the complete span of the request includes the total decode time. Without an acceptance rate per span, the way to detect that spec decode is working is to compare the total decode time divided by the tokens generated: if it is significantly lower than the baseline without spec decode, it is helping.&lt;/p>
&lt;hr>
&lt;h3 id="4-fp8-kv-cache-and-concurrency--vllmgpu_cache_usage_perc--vllmnum_preemptions_total">4. FP8 KV cache and concurrency → &lt;code>vllm:gpu_cache_usage_perc&lt;/code> + &lt;code>vllm:num_preemptions_total&lt;/code>&lt;/h3>
&lt;p>These two metrics are the two sides of KV cache management:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-promql" data-lang="promql">&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># KV cache utilisation (0.0–1.0)&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"># With FP8 enabled, the same hardware supports more requests before saturating&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="nv">vllm&lt;/span>&lt;span class="err">:&lt;/span>&lt;span class="nv">gpu_cache_usage_perc&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="c1"># Cumulative preemptions (counter)&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"># Rises when vLLM cannot fit more requests and pauses one&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="kr">rate&lt;/span>&lt;span class="o">(&lt;/span>&lt;span class="nv">vllm&lt;/span>&lt;span class="err">:&lt;/span>&lt;span class="nv">num_preemptions_total&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s">5m&lt;/span>&lt;span class="p">]&lt;/span>&lt;span class="o">)&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;strong>Sign that FP8 works:&lt;/strong> with &lt;code>--kv-cache-dtype fp8&lt;/code> enabled, &lt;code>gpu_cache_usage_perc&lt;/code> should saturate at concurrency levels around 2× higher than the BF16 baseline before &lt;code>num_preemptions_total&lt;/code> starts to grow.&lt;/p>
&lt;p>&lt;strong>Sign of a problem:&lt;/strong> &lt;code>num_preemptions_total&lt;/code> growing at rates &amp;gt; 1/minute with &lt;code>gpu_cache_usage_perc&lt;/code> below 0.90. It indicates that &lt;code>max-num-seqs&lt;/code> is too high for the available KV cache: requests enter the system but there are no free blocks to assign to them. Lower &lt;code>max-num-seqs&lt;/code> or reduce &lt;code>max-model-len&lt;/code>.&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-promql" data-lang="promql">&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># Alert: KV cache saturated with preemptions&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="nv">ALERT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nv">KVCacheSaturated&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="nv">IF&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kr">rate&lt;/span>&lt;span class="o">(&lt;/span>&lt;span class="nv">vllm&lt;/span>&lt;span class="err">:&lt;/span>&lt;span class="nv">num_preemptions_total&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s">2m&lt;/span>&lt;span class="p">]&lt;/span>&lt;span class="o">)&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">&amp;gt;&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="mf">0.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 class="nv">AND&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nv">vllm&lt;/span>&lt;span class="err">:&lt;/span>&lt;span class="nv">gpu_cache_usage_perc&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">&amp;gt;&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="mf">0.85&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="nv">FOR&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s">3m&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="nv">LABELS&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">{&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nl">severity&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">&amp;#34;&lt;/span>&lt;span class="s">critical&lt;/span>&lt;span class="p">&amp;#34;&lt;/span>&lt;span class="w"> &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="nv">ANNOTATIONS&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">{&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nl">summary&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">&amp;#34;&lt;/span>&lt;span class="s">KV cache saturated: lower max-num-seqs or max-model-len&lt;/span>&lt;span class="p">&amp;#34;&lt;/span>&lt;span class="w"> &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>The impact of FP8 on capacity can be quantified:&lt;/p>
$$\Delta\text{capacity} = \frac{\text{max tokens FP8}}{\text{max tokens BF16}} \approx 2\times$$
&lt;p>Measure before and after enabling &lt;code>--kv-cache-dtype fp8&lt;/code>: the level of &lt;code>gpu_cache_usage_perc&lt;/code> for a given concurrency should fall by half.&lt;/p>
&lt;hr>
&lt;h3 id="5-effective-concurrency--vllmnum_running_seqs--vllmnum_waiting_seqs">5. Effective concurrency → &lt;code>vllm:num_running_seqs&lt;/code> + &lt;code>vllm:num_waiting_seqs&lt;/code>&lt;/h3>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-promql" data-lang="promql">&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># Requests active in the engine (decode + prefill in progress)&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="nv">vllm&lt;/span>&lt;span class="err">:&lt;/span>&lt;span class="nv">num_running_seqs&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="c1"># Requests queued waiting for a slot&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="nv">vllm&lt;/span>&lt;span class="err">:&lt;/span>&lt;span class="nv">num_waiting_seqs&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="c1"># Waiting ratio: if &amp;gt; 0.2 sustained, there is a concurrency bottleneck&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="nv">vllm&lt;/span>&lt;span class="err">:&lt;/span>&lt;span class="nv">num_waiting_seqs&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">/&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">(&lt;/span>&lt;span class="nv">vllm&lt;/span>&lt;span class="err">:&lt;/span>&lt;span class="nv">num_running_seqs&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">+&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nv">vllm&lt;/span>&lt;span class="err">:&lt;/span>&lt;span class="nv">num_waiting_seqs&lt;/span>&lt;span class="o">)&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;strong>Healthy sign:&lt;/strong> &lt;code>num_running_seqs&lt;/code> stable close to the configured &lt;code>--max-num-seqs&lt;/code> value, &lt;code>num_waiting_seqs&lt;/code> low (&amp;lt; 10% of running).&lt;/p>
&lt;p>&lt;strong>Sign of a problem:&lt;/strong> a high &lt;code>num_waiting_seqs&lt;/code> with a low &lt;code>gpu_cache_usage_perc&lt;/code>. It indicates that the scheduler is not filling the available slots because &lt;code>max-num-batched-tokens&lt;/code> is too low: the token budget per step does not allow the pending prefills to be processed fast enough. Raise &lt;code>max-num-batched-tokens&lt;/code>.&lt;/p>
&lt;hr>
&lt;h2 id="reference-dashboard-the-5-metrics-in-grafana">Reference dashboard: the 5 metrics in Grafana&lt;/h2>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-json" data-lang="json">&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="nt">&amp;#34;panels&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="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;title&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;TTFT p50 / p99 (chunked prefill)&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="nt">&amp;#34;targets&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="p">{&lt;/span>&lt;span class="nt">&amp;#34;expr&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;histogram_quantile(0.50, rate(vllm:time_to_first_token_seconds_bucket[5m]))&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nt">&amp;#34;legendFormat&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;p50&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="nt">&amp;#34;expr&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;histogram_quantile(0.99, rate(vllm:time_to_first_token_seconds_bucket[5m]))&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nt">&amp;#34;legendFormat&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;p99&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;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="nt">&amp;#34;title&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;Prefix cache hit rate&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="nt">&amp;#34;targets&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="p">[{&lt;/span>&lt;span class="nt">&amp;#34;expr&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;vllm:gpu_prefix_cache_hit_rate&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nt">&amp;#34;legendFormat&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;GPU hit rate&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;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;title&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;Spec decode acceptance rate&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="nt">&amp;#34;targets&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="p">[{&lt;/span>&lt;span class="nt">&amp;#34;expr&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;vllm:spec_decode_draft_acceptance_rate&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nt">&amp;#34;legendFormat&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;acceptance rate&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;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;title&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;KV cache usage + preemptions&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="nt">&amp;#34;targets&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="p">{&lt;/span>&lt;span class="nt">&amp;#34;expr&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;vllm:gpu_cache_usage_perc&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nt">&amp;#34;legendFormat&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;cache usage&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="nt">&amp;#34;expr&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;rate(vllm:num_preemptions_total[2m]) * 60&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nt">&amp;#34;legendFormat&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;preemptions/min&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;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="nt">&amp;#34;title&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;Effective concurrency&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="nt">&amp;#34;targets&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="p">{&lt;/span>&lt;span class="nt">&amp;#34;expr&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;vllm:num_running_seqs&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nt">&amp;#34;legendFormat&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;running&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="nt">&amp;#34;expr&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;vllm:num_waiting_seqs&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nt">&amp;#34;legendFormat&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;waiting&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;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;hr>
&lt;h2 id="connecting-traces-to-langfuse">Connecting traces to Langfuse&lt;/h2>
&lt;p>vLLM&amp;rsquo;s OTel traces are GenAI semconv compatible spans. Langfuse accepts them directly via OTLP:&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"># In the OTel Collector (already configured above)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># The otlphttp/langfuse exporter sends traces to Langfuse Cloud or self-hosted&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"># For self-hosted Langfuse (ENS/sovereign):&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">exporters:
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> otlphttp/langfuse:
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> endpoint: &lt;span class="s2">&amp;#34;http://langfuse-internal:3000/api/public/otel&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> headers:
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> Authorization: &lt;span class="s2">&amp;#34;Basic &amp;lt;base64(pk_xxx:sk_xxx)&amp;gt;&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>In Langfuse, each vLLM request appears as a trace with:&lt;/p>
&lt;ul>
&lt;li>&lt;code>gen_ai.system&lt;/code>: the model served&lt;/li>
&lt;li>&lt;code>gen_ai.usage.input_tokens&lt;/code>: prompt tokens&lt;/li>
&lt;li>&lt;code>gen_ai.usage.output_tokens&lt;/code>: generated tokens&lt;/li>
&lt;li>Span duration: end-to-end latency&lt;/li>
&lt;/ul>
&lt;p>What does &lt;strong>not&lt;/strong> appear directly in the span: the speculative decoding acceptance rate, the prefix cache hit/miss, or the number of preemptions. That data is only in Prometheus. The correct workflow is:&lt;/p>
&lt;ol>
&lt;li>Langfuse identifies an anomalous request by latency.&lt;/li>
&lt;li>Prometheus/Grafana shows whether in that interval there were high preemptions, low spec decode, or a prefix cache miss.&lt;/li>
&lt;li>The two are correlated by timestamp.&lt;/li>
&lt;/ol>
&lt;hr>
&lt;h2 id="quick-diagnostic-matrix">Quick diagnostic matrix&lt;/h2>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Observable symptom&lt;/th>
&lt;th>Prometheus metric&lt;/th>
&lt;th>Probable cause&lt;/th>
&lt;th>Action&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>TTFT p99 very high&lt;/td>
&lt;td>&lt;code>ttft p99/p50 &amp;gt;&amp;gt; 2&lt;/code>&lt;/td>
&lt;td>Long blocking prefills&lt;/td>
&lt;td>Raise &lt;code>--max-num-batched-tokens&lt;/code>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>TTFT p50 high, p99 the same&lt;/td>
&lt;td>&lt;code>ttft p50 &amp;gt; 500ms&lt;/code>&lt;/td>
&lt;td>Prefix cache not working&lt;/td>
&lt;td>Check the system prompt hash&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Slow decode with no improvement&lt;/td>
&lt;td>&lt;code>spec_decode_acceptance &amp;lt; 0.60&lt;/code>&lt;/td>
&lt;td>Incompatible drafter&lt;/td>
&lt;td>Change the drafter or disable it&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Sporadic OOM / crash&lt;/td>
&lt;td>&lt;code>gpu_cache_usage_perc = 1.0&lt;/code> + preemptions&lt;/td>
&lt;td>KV cache full&lt;/td>
&lt;td>Lower &lt;code>max-num-seqs&lt;/code> or enable FP8&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>High queue with free cache&lt;/td>
&lt;td>&lt;code>waiting &amp;gt;&amp;gt; 0&lt;/code> + &lt;code>cache &amp;lt; 0.70&lt;/code>&lt;/td>
&lt;td>Low token budget&lt;/td>
&lt;td>Raise &lt;code>--max-num-batched-tokens&lt;/code>&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;hr>
&lt;h2 id="implications-for-sovereign-on-premise-inference">Implications for sovereign on-premise inference&lt;/h2>
&lt;p>In an ENS deployment where you cannot use Langfuse Cloud or DataDog, the complete self-hosted stack is:&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"># docker-compose.yml (or equivalent K8s manifests)&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">services&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">otel-collector&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">image&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">otel/opentelemetry-collector-contrib:latest&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">volumes&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="l">./otel-config.yaml:/etc/otel/config.yaml]&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">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">image&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">langfuse/langfuse:latest&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">environment&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">DATABASE_URL&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">postgres://...&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">prometheus&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">image&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">prom/prometheus:latest&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">grafana&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">image&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">grafana/grafana:latest&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>The whole pipeline runs on-premise. The traces never leave the perimeter. ENS compliance does not depend on which observability you choose: it depends on the inference data not going out to third parties. With a local stack, both conditions are met.&lt;/p>
&lt;hr>
&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/">the fundamentals of OTel GenAI semconv: what spans are, the standard attributes and how they flow from the SDK to the collector&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/optimising-prefill-vllm-knobs-ttft-will-not-forgive/">the prefill optimisations this article instruments: chunked prefill, prefix caching, FP8 KV&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/optimising-decode-vllm-squeezing-every-token-small-hardware/">the decode optimisations: speculative decoding, gpu-memory-utilization, max-num-seqs&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/anatomy-dcgm-vllm-metrics-analogies-documented-anomalies/">DCGM for the GPU metrics underneath vLLM: SM utilization, memory bandwidth, temperature; the hardware layer below the application metrics&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/gpu-observability-llm-inference-twelve-dcgm-vllm-metrics/">how to correlate GPU metrics (DCGM) with application metrics (vLLM Prometheus) for a complete diagnosis&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/continuous-batching-orca-vllm-chunked-prefill-goodput/">the scheduler that produces the num_running_seqs and num_waiting_seqs metrics; without understanding the scheduler, the concurrency metrics have no context&lt;/a>&lt;/li>
&lt;/ul>
&lt;hr>
&lt;h2 id="references">References&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://docs.vllm.ai/en/stable/examples/online_serving/opentelemetry/">vLLM OpenTelemetry setup — documentación oficial&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://docs.vllm.ai/en/stable/design/metrics/">vLLM Metrics — diseño y lista completa&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://langfuse.com/integrations/model-providers/vllm">Tracing vLLM with Langfuse via OpenTelemetry&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://www.dash0.com/blog/observing-vllm-with-opentelemetry-and-dash0">Observing vLLM with OpenTelemetry and Dash0&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://opentelemetry.io/blog/2024/llm-observability/">OpenTelemetry GenAI Semantic Conventions&lt;/a>&lt;/li>
&lt;/ul></description></item><item><title>Anatomy of the twelve DCGM and five vLLM metrics: analogies, documented anomalies and real cases 2024-2026</title><link>https://blog.lo0.es/en/posts/anatomy-dcgm-vllm-metrics-analogies-documented-anomalies/</link><pubDate>Tue, 02 Jun 2026 04:00:00 +0200</pubDate><guid>https://blog.lo0.es/en/posts/anatomy-dcgm-vllm-metrics-analogies-documented-anomalies/</guid><description>&lt;blockquote>
&lt;p>This post goes deeper into the list of metrics presented in &lt;a href="https://blog.lo0.es/en/posts/gpu-observability-llm-inference-twelve-dcgm-vllm-metrics/">GPU observability for LLM inference&lt;/a>. There each metric got its G/A/R threshold and PromQL query; here each one gets its explanatory analogy and the anomaly documented in production with a referenced public case. It is the post worth having open when an alert fires and you still do not know what to do with it; the &lt;a href="https://blog.lo0.es/en/posts/incident-response-runbooks-llm-inference-kafka-keep/">next post on runbooks&lt;/a> translates each anomaly into concrete action.&lt;/p>
&lt;/blockquote>
&lt;h2 id="tldr">TL;DR&lt;/h2>
&lt;p>The twelve DCGM metrics (compute, memory, thermal-power, health) and the five vLLM engine metrics (concurrency, KV pool, SLO latencies) covered in the previous post paint the cluster&amp;rsquo;s cockpit, but a list without context does not teach you to diagnose. Each metric has a &lt;strong>recurring anomalous pattern&lt;/strong> documented in the public literature (academic papers, GitHub issues, OEM KBs, operator blogs) that the veteran operator recognises instantly and the junior one does not. This post develops each metric with an &lt;strong>analogy of its own&lt;/strong> that pins down which question it answers, and with the &lt;strong>statistically relevant anomaly&lt;/strong> backed by figures from documented incidents. Three examples of the calibre involved: &lt;strong>Meta&lt;/strong> published that during the training of Llama 3 405B on 16,384 H100s there were &lt;strong>419 unplanned failures in 54 days&lt;/strong>, one every 3 hours, with GPU + HBM3 accounting for 47 % of the total; the paper &lt;em>Story of Two GPUs&lt;/em> (arXiv 2503.11901) quantifies that &lt;strong>the H100 has 3.2× worse MTBE from uncorrectable ECC than the A100&lt;/strong>, attributable to the higher density of HBM3; issue &lt;strong>vllm#16300&lt;/strong> documents that on a cluster of 8×A100 80 GB &lt;strong>TP=8 delivers worse throughput than TP=4&lt;/strong> because NVLink saturation kills the partition speedup. The KBs &lt;strong>Dell 000220508&lt;/strong> and &lt;strong>Lenovo HT514380&lt;/strong> formalise the recurring &lt;em>HW Power Brake&lt;/em> case in H100 racks overcommitted at PDU level. Issue &lt;strong>vllm#25677&lt;/strong> showed &lt;em>chunked prefill&lt;/em> 10× slower than without it on Qwen3-30B-A3B (bad calibration of &lt;code>max_num_batched_tokens&lt;/code>). Issue &lt;strong>vllm#11912&lt;/strong> documents a TPOT regression from 15.7 ms to 25.7 ms crossing version 0.6.4. Each case includes a verifiable URL. The operational rule: when an alert arrives, look first at the anomalous pattern associated with the metric that fired, &lt;strong>before&lt;/strong> opening the request&amp;rsquo;s trace; 80 % of degradations match one of the documented patterns.&lt;/p>
&lt;h2 id="you-are-here-observe--the-diagnosis-layer">You are here: OBSERVE — the diagnosis layer&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, diagnosis layer">
&lt;style>.box{stroke:#444;stroke-width:1.4;rx:6}.active{fill:#c9a8e9;stroke-width:3}.idle{fill:#f4f4f4}.lbl{font:600 12px sans-serif;fill:#222}.arr{stroke:#666;stroke-width:1.4;fill:none;marker-end:url(#anm)}.cyc{stroke:#888;stroke-width:1.2;fill:none;stroke-dasharray:4 2;marker-end:url(#anm)}&lt;/style>
&lt;defs>&lt;marker id="anm" 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" class="lbl">You are here: OBSERVE · each metric is a question with a typical anomaly attached&lt;/text>
&lt;rect x="30" y="35" width="110" height="35" class="box idle"/>&lt;text x="85" y="58" text-anchor="middle" class="lbl">1 · Data&lt;/text>
&lt;rect x="155" y="35" width="110" height="35" class="box idle"/>&lt;text x="210" y="58" text-anchor="middle" class="lbl">2 · Tune&lt;/text>
&lt;rect x="280" y="35" width="110" height="35" class="box idle"/>&lt;text x="335" y="58" text-anchor="middle" class="lbl">3 · Eval&lt;/text>
&lt;rect x="405" y="35" width="110" height="35" class="box idle"/>&lt;text x="460" y="58" text-anchor="middle" class="lbl">4 · Deploy&lt;/text>
&lt;rect x="530" y="35" width="110" height="35" class="box active"/>&lt;text x="585" y="58" text-anchor="middle" class="lbl">5 · Observe&lt;/text>
&lt;rect x="655" y="35" width="110" height="35" class="box idle"/>&lt;text x="710" y="58" text-anchor="middle" class="lbl">6 · Retrain&lt;/text>
&lt;path class="arr" d="M140,52 L155,52"/>&lt;path class="arr" d="M265,52 L280,52"/>&lt;path class="arr" d="M390,52 L405,52"/>&lt;path class="arr" d="M515,52 L530,52"/>&lt;path class="arr" d="M640,52 L655,52"/>
&lt;path class="cyc" d="M710,72 L710,82 L85,82 L85,72"/>
&lt;/svg>
&lt;/div>
&lt;h2 id="family-1--compute">Family 1 — Compute&lt;/h2>
&lt;h3 id="dcgm_fi_prof_sm_occupancy--is-there-parallel-work-in-the-engines">&lt;code>DCGM_FI_PROF_SM_OCCUPANCY&lt;/code> — is there parallel work in the engines?&lt;/h3>
&lt;p>&lt;strong>The analogy.&lt;/strong> An industrial kitchen with 32 hobs and a single chef. The metric answers &lt;em>&amp;ldquo;how many hobs have a pan on them right now?&amp;rdquo;&lt;/em>. If half are empty, the kitchen is under-used: the orders come out one after another because the chef does not open up parallelism. If all of them are occupied but the chef is standing still watching a stopwatch, the hobs are on but nothing is being cooked (a pathological kernel saturating SMs without doing useful work).&lt;/p>
&lt;p>&lt;strong>The documented anomaly.&lt;/strong> The best-known trap: &lt;strong>high SM occupancy does not imply real throughput&lt;/strong>. The article &lt;em>&amp;ldquo;GPU Utilization Is a Counter, Not a Cause&amp;rdquo;&lt;/em> (Ingero, May 2026) put it in one exact sentence: &lt;em>&amp;ldquo;a kernel running at 5 % of peak FLOPS for 100 ms still reads 100 % on SM_ACTIVE&amp;rdquo;&lt;/em>. In MoE workloads the effect turns pathological: overloaded experts produce the &lt;strong>Straggler Effect&lt;/strong> (paper arXiv 2503.05066). The SMs look busy while waiting for the saturated expert, and the utilisation dashboard paints green while latency goes through the roof.&lt;/p>
&lt;p>&lt;strong>Operational implication.&lt;/strong> Do not trust sizing or autoscaling to SM occupancy alone. Always combine it with &lt;code>PIPE_TENSOR_ACTIVE&lt;/code> (is there useful compute?) and &lt;code>DRAM_ACTIVE&lt;/code> (is memory the bottleneck?). The normal LLM decode regime is 30–55 %, not 99 %; seeing a sustained 99 % with high TPOT is a symptom of a kernel bug or an MoE straggler.&lt;/p>
&lt;h3 id="dcgm_fi_prof_pipe_tensor_active--are-the-tensor-cores-producing">&lt;code>DCGM_FI_PROF_PIPE_TENSOR_ACTIVE&lt;/code> — are the tensor cores producing?&lt;/h3>
&lt;p>&lt;strong>The analogy.&lt;/strong> A factory with two lines: the manual one (CUDA cores) and the automated one (tensor cores). The metric answers &lt;em>&amp;ldquo;what percentage of the time is the automated line active?&amp;rdquo;&lt;/em>. If you buy an H100 for its tensor cores and the automated line is at 5 %, you have paid for a Ferrari to do bicycle courier work.&lt;/p>
&lt;p>&lt;strong>The documented anomaly.&lt;/strong> Issue &lt;strong>vllm#20783&lt;/strong> (July 2025) was titled literally &lt;em>&amp;ldquo;Performance Anomaly: compressed-tensors shows no speedup over BF16 on H100&amp;rdquo;&lt;/em>. The operator expected 1.5–2× with FP8 quantisation and got parity with BF16; the &lt;code>PIPE_TENSOR_ACTIVE&lt;/code> metric revealed that the FP8 path was not running on the HMMA units (the tensor unit for FP16/BF16/FP8) and was falling back to CUDA cores. Issue &lt;strong>vllm#31475&lt;/strong> documented the parallel case on MI300X: FP8 slower than BF16 because of a regression in the ROCm path. DCGM exposes separate counters per unit (&lt;code>HMMA&lt;/code> for FP16/BF16/FP8, &lt;code>IMMA&lt;/code> for INT8, &lt;code>DMMA&lt;/code> for TF32/FP32); if &lt;code>HMMA&lt;/code> is low even though the model is BF16, the engine is not using tensor cores.&lt;/p>
&lt;p>&lt;strong>Operational implication.&lt;/strong> Check &lt;code>PIPE_TENSOR_ACTIVE&lt;/code> after every change of quantisation or engine version; a supposedly neutral change may have disabled the optimised path. For prefill expect 50–80 %; for decode 15–30 % is normal (decode is memory-bound, not compute-bound). A figure below 5 % in prefill means the engine is not using tensor cores.&lt;/p>
&lt;h3 id="dcgm_fi_prof_dram_active--is-the-hbm-saturated">&lt;code>DCGM_FI_PROF_DRAM_ACTIVE&lt;/code> — is the HBM saturated?&lt;/h3>
&lt;p>&lt;strong>The analogy.&lt;/strong> A motorway with N lanes. The metric answers &lt;em>&amp;ldquo;what percentage of the time are all the lanes occupied moving cars?&amp;rdquo;&lt;/em>. When the tensor cores ask for data faster than the HBM delivers it, the motorway is at 95 % and the engines wait. In decode, this is the normal regime: you walk the model weights and the KV cache through for every token.&lt;/p>
&lt;p>&lt;strong>The documented anomaly.&lt;/strong> The paper &lt;em>&amp;ldquo;Mind the Memory Gap: Unveiling GPU Bottlenecks in Large-Batch LLM Inference&amp;rdquo;&lt;/em> (arXiv 2503.08311) quantifies that at contexts ≥ 128k, reading the KV cache &lt;strong>dominates the total decode time&lt;/strong> and saturates the HBM3 (3.35 TB/s on the H100). The distinctive pattern: &lt;code>DRAM_ACTIVE&lt;/code> &amp;gt; 80 % with &lt;code>PIPE_TENSOR_ACTIVE&lt;/code> around 10–20 %. Raising the batch no longer helps: the bottleneck is not FLOPS, it is bandwidth. The useful lever is compressing the KV, see &lt;a href="https://blog.lo0.es/en/posts/quantization-llm-inference-fp8-int4-gguf/">Quantisation&lt;/a> for &lt;code>--kv-cache-dtype=fp8&lt;/code>, which cuts the KV footprint by roughly 50 %.&lt;/p>
&lt;p>&lt;strong>Operational implication.&lt;/strong> If &lt;code>DRAM_ACTIVE &amp;gt; 95 %&lt;/code> is sustained and &lt;code>gpu_cache_usage_perc &amp;lt; 70 %&lt;/code>, &lt;strong>something other than your engine is asking for HBM&lt;/strong> (a leak in a library, another process sharing the GPU without MIG). Investigate immediately with &lt;code>nvidia-smi&lt;/code> and &lt;code>fuser /dev/nvidia*&lt;/code>.&lt;/p>
&lt;h2 id="family-2--memory">Family 2 — Memory&lt;/h2>
&lt;h3 id="dcgm_fi_dev_fb_used--how-much-vram-has-been-consumed">&lt;code>DCGM_FI_DEV_FB_USED&lt;/code> — how much VRAM has been consumed?&lt;/h3>
&lt;p>&lt;strong>The analogy.&lt;/strong> The fuel level in an aircraft&amp;rsquo;s tank in flight: the pilot needs to know how much is left &lt;strong>and at what rate it is being consumed&lt;/strong>, not just the instantaneous figure. An H100 at 88 % FB used and &lt;strong>stable&lt;/strong> can operate calmly; the same figure &lt;strong>rising 2 %/min&lt;/strong> announces OOM in 7 minutes.&lt;/p>
&lt;p>&lt;strong>The documented anomaly.&lt;/strong> Issue &lt;strong>dcgm-exporter#512&lt;/strong> documents a surprise relevant to MIG clusters: &lt;strong>&lt;code>DCGM_FI_DEV_FB_USED&lt;/code> and &lt;code>DCGM_FI_DEV_FB_FREE&lt;/code> are absent on H100 GPU instances with MIG enabled&lt;/strong>, present on A100 and B200, but an exporter bug hides them on H100-MIG. Operators who assume the dashboard covers everything discover the blind spot on the day of the first OOM. Issue &lt;strong>dcgm-exporter#271&lt;/strong> documents another detail: &lt;code>FB_USED + FB_FREE&lt;/code> &lt;strong>does not always add up to a constant&lt;/strong> because there is driver-reserved overhead that shows up in the delta. The original &lt;strong>PagedAttention/vLLM&lt;/strong> paper estimated that pre-PagedAttention serving frameworks wasted &lt;strong>60–80 %&lt;/strong> of the KV cache to fragmentation; PagedAttention brought it down to under 4 %.&lt;/p>
&lt;p>&lt;strong>Operational implication.&lt;/strong> On H100 MIG clusters, check that &lt;code>DCGM_FI_DEV_FB_USED&lt;/code> appears per instance before trusting alerts; if it is absent, monitor through &lt;code>nvidia-smi --query-gpu=memory.used&lt;/code> directly. Operational rule: alert on the &lt;strong>delta&lt;/strong> (a sustained rise), not just an absolute threshold.&lt;/p>
&lt;h3 id="dcgm_fi_dev_fb_free--the-absolute-complement">&lt;code>DCGM_FI_DEV_FB_FREE&lt;/code> — the absolute complement&lt;/h3>
&lt;p>&lt;strong>The analogy.&lt;/strong> The &amp;ldquo;range remaining&amp;rdquo; indicator in a modern car: it complements the percentage with an absolute figure that is directly actionable.&lt;/p>
&lt;p>&lt;strong>The documented anomaly.&lt;/strong> When an aggressive PagedAttention pool leaves &lt;code>FB_FREE&lt;/code> at small absolute values (&amp;lt; 2 GiB), any normal allocation of transient buffers (the activations of a large prefill) can push you into OOM. The classic pattern: a &amp;ldquo;green&amp;rdquo; percentage (87 %) but a &amp;ldquo;red&amp;rdquo; absolute (&amp;lt; 4 GiB free on an 80 GB H100).&lt;/p>
&lt;p>&lt;strong>Operational implication.&lt;/strong> A complementary alert with an absolute threshold: &lt;code>DCGM_FI_DEV_FB_FREE &amp;lt; 4096&lt;/code> (MiB). It is the safety net for the cases where the percentage misleads because the engine is configured with a very high &lt;code>gpu_memory_utilization&lt;/code>.&lt;/p>
&lt;h3 id="dcgm_fi_dev_nvlink_bandwidth_total--does-the-internal-bus-hold-up">&lt;code>DCGM_FI_DEV_NVLINK_BANDWIDTH_TOTAL&lt;/code> — does the internal bus hold up?&lt;/h3>
&lt;p>&lt;strong>The analogy.&lt;/strong> An interstate motorway between four cities. Every car that crosses to do a tensor parallel all-reduce pays a toll and consumes width. When there are more cars than the motorway supports, the latency to reach the destination shoots up, even if each individual car is fast.&lt;/p>
&lt;p>&lt;strong>The documented anomaly.&lt;/strong> Issue &lt;strong>vllm#16300&lt;/strong> (April 2025) was titled &lt;em>&amp;ldquo;Performance degradation with tp=8 compared to tp=4 on 8×A100(80G)&amp;rdquo;&lt;/em> and documented &lt;strong>TP=8 delivering worse throughput than TP=4&lt;/strong> on the same cluster, same model, same quantisation. Root cause: tensor parallelism requires an all-reduce after each attention and MLP block; at TP=8, the communication cost between 8 GPUs (even over NVSwitch) grows faster than the speedup of partitioning the compute. The practical rule that emerges: &lt;strong>TP=4 + 2 replicas&lt;/strong> usually delivers better latency/throughput than &lt;strong>TP=8 + 1 replica&lt;/strong>, except for extremely long contexts (≥128k) where you need the aggregate VRAM. Theoretical NVLink 4.0 capacity on the H100 SXM: around 450 GB/s per GPU; typical sustained TP=4 regime: 50–150 GB/s.&lt;/p>
&lt;p>&lt;strong>Operational implication.&lt;/strong> If &lt;code>NVLINK_BANDWIDTH_TOTAL &amp;gt; 90 %&lt;/code> of capacity is sustained, this is not a problem &lt;em>solvable by raising parallelism&lt;/em>. The opposite: &lt;strong>lower TP&lt;/strong>. The metric is orthogonal to the sizing from &lt;a href="https://blog.lo0.es/en/posts/capacity-planning-on-premise-llm-inference/">capacity planning&lt;/a>: the ceiling is not only VRAM/time, it is also the bus.&lt;/p>
&lt;h2 id="family-3--thermal-and-power">Family 3 — Thermal and power&lt;/h2>
&lt;h3 id="dcgm_fi_dev_gpu_temp--is-the-gpu-breathing">&lt;code>DCGM_FI_DEV_GPU_TEMP&lt;/code> — is the GPU breathing?&lt;/h3>
&lt;p>&lt;strong>The analogy.&lt;/strong> The body temperature of an elite athlete under full effort. 36–37 °C is normal; 38 °C is sustainable stress; above 39 °C the body activates protective mechanisms (sweating, slowing down) that &lt;strong>degrade performance&lt;/strong>. The GPU does the same: above a thermal threshold, it reduces its clock automatically. If it did not, it would break.&lt;/p>
&lt;p>&lt;strong>The documented anomaly.&lt;/strong> The H100 SXM5 with its 700 W TDP has thermal thresholds that are not entirely public (NVIDIA does not publish them exhaustively in the datasheet), but the behaviour is well known: above roughly 85 °C edge or roughly 95 °C HBM, the bit &lt;code>0x40 HW_THERMAL&lt;/code> appears in the clock throttle reasons. Operators on the NVIDIA developer forum report that with a &lt;strong>rack inlet temperature above 27 °C&lt;/strong>, throttling is routine. The NVIDIA HGX Platform paper states that the minimum recommended air flow is &lt;strong>&amp;gt; 1000 CFM/kW&lt;/strong>; densities above 30 kW/rack at 700 W TDP demand mandatory liquid cooling because forced air does not get there.&lt;/p>
&lt;p>&lt;strong>Operational implication.&lt;/strong> If &lt;code>GPU_TEMP &amp;gt; 83 °C&lt;/code> is sustained, look first at &lt;code>CLOCK_THROTTLE_REASONS&lt;/code> (bit 0x40) and at the rack inlet temperature: this is not an engine problem, it is an air flow one. For legacy air-cooled racks, consider redistributing thermal load or installing a rear-door HX.&lt;/p>
&lt;h3 id="dcgm_fi_dev_power_usage--how-much-is-it-asking-of-the-socket">&lt;code>DCGM_FI_DEV_POWER_USAGE&lt;/code> — how much is it asking of the socket?&lt;/h3>
&lt;p>&lt;strong>The analogy.&lt;/strong> The instantaneous draw of an industrial appliance plugged into a three-phase outlet with a sized breaker. If the washing machine starts at 9 kW and the breaker is 10 kW, you live on the edge; if the washing machine &amp;ldquo;gets along&amp;rdquo; with the breaker it is because somebody sized it consciously.&lt;/p>
&lt;p>&lt;strong>The documented anomaly.&lt;/strong> A published real measurement: an H100 SXM5 with vLLM running Llama 3.1 405B at batch=4 draws &lt;strong>around 697 W at-wall&lt;/strong> sustained (NVIDIA TDP 700 W). Now the interesting operational lever: &lt;strong>lowering &lt;code>nvidia-smi -pl&lt;/code> from 700 W to 500 W&lt;/strong> delivers around 30 % energy savings with only around 20 % loss of throughput. A cluster of 4 nodes × 8 H100 at 700 W equals around 22 kW of GPU alone; at 500 W, around 16 kW. The difference pays an entire quarter&amp;rsquo;s electricity bill in clusters operated around the clock. A three-phase 415 VAC PDU branch at 60–80 A supports around 32 kW, roughly 4 DGX H100. Legacy 208 V does not support H100 density; reference: NVIDIA DGX SuperPOD Electrical Specifications.&lt;/p>
&lt;p>&lt;strong>Operational implication.&lt;/strong> A useful metric for three things: (1) detecting anomalously low workloads (unexpected idle), (2) computing per-tenant showback of real energy cost (not estimates), (3) alerting if the draw approaches the PDU branch limit. Keep &lt;strong>GPU → PDU branch → breaker&lt;/strong> mapped in the CMDB.&lt;/p>
&lt;h3 id="dcgm_fi_dev_clock_throttle_reasons--who-is-stepping-on-the-brake">&lt;code>DCGM_FI_DEV_CLOCK_THROTTLE_REASONS&lt;/code> — who is stepping on the brake?&lt;/h3>
&lt;p>&lt;strong>The analogy.&lt;/strong> The &amp;ldquo;limited mode&amp;rdquo; warning light on the dashboard of a modern car. When it lights up, the car reduces its performance automatically, but &lt;strong>it does not tell you why&lt;/strong> unless you know how to read the combination of letters. The bits of the bitmap are those letters.&lt;/p>
&lt;p>&lt;strong>The documented anomaly.&lt;/strong> A public case formally acknowledged by two different OEMs: &lt;strong>Dell KB 000220508&lt;/strong> and &lt;strong>Lenovo HT514380&lt;/strong> address the same phenomenon: &lt;em>HW Power Brake Slowdown active&lt;/em> (bit &lt;code>0x80&lt;/code>) on the H100 SXM. The cause is not the GPU: it is the chassis PDU sending an electrical power-brake signal because the rack branch is close to the breaker limit. The operator sees throughput down 30–50 % with no XID and no ECC, and the inference engine &amp;ldquo;is healthy&amp;rdquo;; the problem is in the electrics. The NVIDIA developer forum thread on &lt;em>&amp;ldquo;HW Power Brake Slowdown&amp;rdquo;&lt;/em> corroborates the pattern. Bit &lt;code>0x40 HW_THERMAL&lt;/code> appears in badly ventilated racks; bit &lt;code>0x04 SW_POWER_CAP&lt;/code> appears if somebody left &lt;code>nvidia-smi -pl 500&lt;/code> set and nobody reverts it.&lt;/p>
&lt;p>&lt;strong>Operational implication.&lt;/strong> Any bit other than 0 or &lt;code>Idle&lt;/code> (bit 0x01) sustained &lt;strong>is an immediate alert&lt;/strong>. The recommended decoding: record the complete bitmap value in the log plus the attribute &lt;code>throttle.reasons.decoded=[&amp;quot;HW_THERMAL&amp;quot;, &amp;quot;HW_POWER_BRAKE&amp;quot;]&lt;/code> on the OTel span. Without that, incident response does not know what to do.&lt;/p>
&lt;h2 id="family-4--health-the-catastrophic-reports">Family 4 — Health (the catastrophic reports)&lt;/h2>
&lt;h3 id="dcgm_fi_dev_xid_errors--the-drivers-red-codes">&lt;code>DCGM_FI_DEV_XID_ERRORS&lt;/code> — the driver&amp;rsquo;s red codes&lt;/h3>
&lt;p>&lt;strong>The analogy.&lt;/strong> The critical alarm lights in a nuclear control room. They do not climb gradually; they either appear or they do not. Each XID is a predefined code (XID 13 = graphics engine exception; XID 31 = MMU fault; XID 43 = stopped channel; XID 79 = GPU fallen off the bus; XID 95 = uncontained ECC), and each one has its documented procedure.&lt;/p>
&lt;p>&lt;strong>The documented anomaly.&lt;/strong> The most studied public case: &lt;strong>Meta&lt;/strong> published that during the training of Llama 3 405B on &lt;strong>16,384 H100s over 54 days&lt;/strong> there were &lt;strong>419 unplanned failures&lt;/strong>, one every 3 hours at cluster scale. GPUs accounted for 148 (35 %) plus HBM3 for 72 (17 %), almost half of all failures. The paper &lt;em>&amp;ldquo;Story of Two GPUs: Characterizing the Resilience of Hopper H100 and Ampere A100&amp;rdquo;&lt;/em> (arXiv 2503.11901) quantifies with a different dataset (2.1M GPU-hours) that the H100 has &lt;strong>3.2× worse MTBE for uncorrectable ECC than the A100&lt;/strong>. The ByteDance MegaScale paper reports that XID 79 (&amp;ldquo;GPU fallen off the bus&amp;rdquo;) co-occurs with PCIe errors in &lt;strong>43 % of cases&lt;/strong>. The NVIDIA developer forum documents persistent cases of XID 31 (MMU fault) that &lt;strong>follow the GPU when it is moved to a different PCIe slot&lt;/strong>, a hardware bug in the module, not in the backplane.&lt;/p>
&lt;p>&lt;strong>Operational implication.&lt;/strong> &lt;strong>Any increment of the counter is an immediate alert&lt;/strong>: many XIDs demand a node reset or an RMA of the GPU. The XID-by-XID distinction matters: XID 13/43 is usually a software bug if it coincides with a recent change; XID 31/48/79/94/95 is usually hardware. Keep a canonical &lt;code>xid → procedure&lt;/code> table. See &lt;a href="https://blog.lo0.es/en/posts/incident-response-runbooks-llm-inference-kafka-keep/">the runbooks&lt;/a> for the translation into concrete action.&lt;/p>
&lt;h3 id="dcgm_fi_dev_ecc_dbe_vol_total--the-errors-that-corrupt-data">&lt;code>DCGM_FI_DEV_ECC_DBE_VOL_TOTAL&lt;/code> — the errors that corrupt data&lt;/h3>
&lt;p>&lt;strong>The analogy.&lt;/strong> An accounting ledger where sometimes someone erases an entry and rewrites it (corrected single-bit ECC: note a change in the margin and carry on) and sometimes someone burns two pages at once (double-bit: the information is lost, the audit has to stop).&lt;/p>
&lt;p>&lt;strong>The documented anomaly.&lt;/strong> The paper &lt;em>&amp;ldquo;Characterizing GPU Resilience&amp;rdquo;&lt;/em> quantifies for the H100 that when XID 48 (DBE) appears, &lt;strong>the job in flight dies with 100 % probability&lt;/strong> (5 out of 5 in the dataset studied). The documented recovery: drain the node, reset, complete the row remap, equalling &lt;strong>around 19 hours of node downtime&lt;/strong>. HBM3 density explains the worse MTBE against HBM2e: there are more cells per unit area, and therefore a higher statistical probability of degradation. In Llama 3, HBM3 caused 72 of the 419 interruptions (17 %).&lt;/p>
&lt;p>&lt;strong>Operational implication.&lt;/strong> Any value &amp;gt; 0 is a critical alert. The GPU must be &lt;strong>drained immediately&lt;/strong>, removed from the scheduler, fully reset, validated for row remap with &lt;code>nvidia-smi -q -d ROW_REMAPPER&lt;/code> (&lt;code>Pending: No&lt;/code>), and given an extensive smoke test before returning to the pool. If the row remap uses more than 4–8 spare pages on one GPU, plan a replacement in the next window: the degradation is progressive.&lt;/p>
&lt;h3 id="dcgm_fi_dev_retired_dbe--the-pages-marked-for-retirement">&lt;code>DCGM_FI_DEV_RETIRED_DBE&lt;/code> — the pages marked for retirement&lt;/h3>
&lt;p>&lt;strong>The analogy.&lt;/strong> The tiles a museum restorer marks with yellow tape because they are damaged. They pose no immediate danger (the room stays open), but the accumulation says the floor is degrading structurally and the full replacement has to be planned.&lt;/p>
&lt;p>&lt;strong>The documented anomaly.&lt;/strong> NVIDIA documents up to &lt;strong>512 spare pages per HBM bank&lt;/strong> on the H100; the &lt;code>RETIRED_DBE&lt;/code> counter indicates how many have been used. Operators on NVIDIA forums report that above &lt;strong>4–8 retired pages on a specific GPU&lt;/strong>, the frequency of XID 48 rises. The pattern: a GPU with 6 retired pages today becomes 12 in a month, a first XID 48 two months later, and a forced drain.&lt;/p>
&lt;p>&lt;strong>Operational implication.&lt;/strong> A trend metric, not an immediate alert. Record the value per GPU and review it monthly; GPUs with rising values go into the proactive replacement plan before the catastrophic failure.&lt;/p>
&lt;h2 id="the-five-vllm-engine-metrics">The five vLLM engine metrics&lt;/h2>
&lt;h3 id="vllmnum_requests_running--how-many-requests-fit-in-the-batch">&lt;code>vllm:num_requests_running&lt;/code> — how many requests fit in the batch?&lt;/h3>
&lt;p>&lt;strong>The analogy.&lt;/strong> The number of cars a toll booth lets through at once. If the barrier opens for N at a time, car N+1 waits in the queue. Saturation shows up because the line does not get shorter.&lt;/p>
&lt;p>&lt;strong>The documented anomaly.&lt;/strong> Reaching the configured &lt;code>--max-num-seqs&lt;/code> and staying there is the typical symptom of a &lt;strong>cluster below its sizing&lt;/strong>; the engine admits up to the ceiling and no further. The query &lt;code>vllm:num_requests_running == max_num_seqs&lt;/code> for more than 5 minutes indicates firm saturation.&lt;/p>
&lt;p>&lt;strong>Operational implication.&lt;/strong> Combine it with &lt;code>num_requests_waiting&lt;/code>: if running is at the ceiling AND waiting &amp;gt; 0, you have to scale. If running is at the ceiling and waiting is 0, you are in the optimal regime (cluster used to the maximum with no queue).&lt;/p>
&lt;h3 id="vllmnum_requests_waiting--the-primary-saturation-indicator">&lt;code>vllm:num_requests_waiting&lt;/code> — the primary saturation indicator&lt;/h3>
&lt;p>&lt;strong>The analogy.&lt;/strong> The visible queue in front of the toll booth. As long as it is empty, the system flows; the moment a sustained queue forms, drivers start arriving late at their destination, and TTFT goes through the roof.&lt;/p>
&lt;p>&lt;strong>The documented anomaly.&lt;/strong> A public case in &lt;em>&amp;ldquo;11-Second Time to First Token on a Healthy vLLM Server&amp;rdquo;&lt;/em> (Medium, Ingero, 2026): a server with no XIDs, no preemption, DCGM metrics all green, but &lt;code>num_requests_waiting&lt;/code> sustained above 0 and a TTFT of &lt;strong>11 seconds&lt;/strong>. Issue &lt;strong>vllm#16985&lt;/strong> documents progressive degradation in long sessions: the queue grows slowly over hours without any other indicator moving. The root cause is not hardware, it is &lt;strong>admission control&lt;/strong>: the arrival rate exceeds the completion rate and the system does not enqueue any more, it leaves requests in &lt;code>WAITING&lt;/code>. Red Hat designates it as &lt;strong>the primary saturation metric&lt;/strong> in its tutorial &lt;em>&amp;ldquo;5 steps to triage vLLM performance&amp;rdquo;&lt;/em>.&lt;/p>
&lt;p>&lt;strong>Operational implication.&lt;/strong> The primary HPA metric in KEDA, see &lt;a href="https://blog.lo0.es/en/posts/llm-inference-autoscaling-kubernetes-hpa-keda/">Autoscaling LLM on Kubernetes&lt;/a>. Typical threshold: alert if &lt;code>avg_over_time(vllm:num_requests_waiting[5m]) &amp;gt; 5&lt;/code>. For canaries: if the queue only forms in the v2 pool, it is a regression of the new model, not cluster load.&lt;/p>
&lt;h3 id="vllmgpu_cache_usage_perc--the-kv-cache-pool">&lt;code>vllm:gpu_cache_usage_perc&lt;/code> — the KV cache pool&lt;/h3>
&lt;p>&lt;strong>The analogy.&lt;/strong> The capacity of an events hall where each guest takes up a variable amount of space. The maître d&amp;rsquo; admits guests up to capacity; when a new guest arrives and there is no room, he &lt;strong>throws out the guest who has been there longest&lt;/strong> to make space for the newcomer. That is vLLM&amp;rsquo;s preempt-on-OOM.&lt;/p>
&lt;p>&lt;strong>The documented anomaly.&lt;/strong> Issue &lt;strong>vllm#5051&lt;/strong> &lt;em>&amp;ldquo;Add num_requests_preempted metric&amp;rdquo;&lt;/em> was born exactly out of operators observing degradation with no direct metric telling them how many requests were being thrown out. The official vLLM documentation confirms: &lt;em>&amp;ldquo;sustained &lt;code>gpu_cache_usage_perc&lt;/code> above 90 % indicates the server is approaching its KV cache limit and will begin preempting sequences&amp;rdquo;&lt;/em> (oldest-first). The distinctive visual pattern: a &lt;strong>sawtooth near 100 %&lt;/strong> with preemption spikes. In &lt;code>swap&lt;/code> mode, the latency of the preempted request explodes because there is a host↔device PCIe copy; in &lt;code>recompute&lt;/code> mode (the default in V1), the preempted request redoes its prefill from scratch, which pushes its TTFT to double or triple.&lt;/p>
&lt;p>&lt;strong>Operational implication.&lt;/strong> If &lt;code>gpu_cache_usage_perc &amp;gt; 92 %&lt;/code> is sustained, there are &lt;strong>two levers&lt;/strong>: lower &lt;code>max_num_seqs&lt;/code> (admit less concurrency, but none of it gets preempted) or raise &lt;code>gpu_memory_utilization&lt;/code> (more pool, less VRAM for transient activations, a different risk). The choice depends on the workload. The metric that is directly missing, a preempted counter, is exported from vLLM v1.0 onwards as &lt;code>vllm:num_preemptions_total&lt;/code> (see issue #5051).&lt;/p>
&lt;h3 id="vllmtime_to_first_token_seconds--the-latency-the-client-sees">&lt;code>vllm:time_to_first_token_seconds&lt;/code> — the latency the client sees&lt;/h3>
&lt;p>&lt;strong>The analogy.&lt;/strong> The time from a customer walking into a restaurant to receiving the first piece of bread at the table. Too long and the customer thinks they have been forgotten, even if the main course is going to arrive perfect.&lt;/p>
&lt;p>&lt;strong>The documented anomaly.&lt;/strong> Three documented patterns of recurring TTFT spikes:&lt;/p>
&lt;ol>
&lt;li>&lt;strong>Badly calibrated chunked prefill.&lt;/strong> Issue &lt;strong>vllm#25677&lt;/strong> (Qwen3-30B-A3B) reported &lt;strong>prefill 10–11× slower with chunked prefill enabled&lt;/strong> than without it. Cause: a very low &lt;code>max_num_batched_tokens&lt;/code> forces small chunks that do not fill the kernels. Issue &lt;strong>vllm#7604&lt;/strong> documents an equivalent regression on Llama-3-70B v0.5.4. The lever: raise &lt;code>max_num_batched_tokens&lt;/code> to 4096–8192 for typical prompts above 2k.&lt;/li>
&lt;li>&lt;strong>Regression between engine versions.&lt;/strong> Issue &lt;strong>vllm#8819&lt;/strong> documents a regression of &lt;code>vllm:time_to_first_token_seconds_sum&lt;/code> between minor versions. Issue &lt;strong>vllm#11912&lt;/strong> reports that with a prompt of around 8000 tokens, TPOT rose from &lt;strong>15.7 ms → 25.7 ms&lt;/strong> from v0.6.4.post1 onwards with no config change, a regression confirmed and trackable only with the metric.&lt;/li>
&lt;li>&lt;strong>Long-context prefill blocking decodes.&lt;/strong> The &lt;em>&amp;ldquo;11s TTFT on healthy server&amp;rdquo;&lt;/em> case cited above: a 30k-token prefill monopolises the GPU for several seconds and the active decodes freeze. Fix: well-calibrated chunked prefill, or disaggregated serving (see &lt;a href="https://blog.lo0.es/en/posts/disaggregated-serving-prefill-decode-specialised-pods/">Disaggregated serving&lt;/a>).&lt;/li>
&lt;/ol>
&lt;p>&lt;strong>Operational implication.&lt;/strong> Do not alert only on the absolute P95; alert also on the &lt;strong>v2/v1 ratio&lt;/strong> when there is a canary (&lt;code>histogram_quantile(0.95, ..., version=&amp;quot;v2&amp;quot;) / histogram_quantile(0.95, ..., version=&amp;quot;v1&amp;quot;) &amp;gt; 1.10&lt;/code>). If TTFT grows and the queue is stable, the bottleneck is prefill: not solvable by adding replicas, but there are levers in quantisation or chunked prefill.&lt;/p>
&lt;h3 id="vllmtime_per_output_token_seconds--streaming-smoothness">&lt;code>vllm:time_per_output_token_seconds&lt;/code> — streaming smoothness&lt;/h3>
&lt;p>&lt;strong>The analogy.&lt;/strong> The speed at which the waiter brings the courses one after another after the first. If the next one takes a while, the diner notices something is wrong even though the first course arrived on time.&lt;/p>
&lt;p>&lt;strong>The documented anomaly.&lt;/strong> The distinctive pattern is the &lt;strong>abrupt step when &lt;code>gpu_cache_usage_perc&lt;/code> crosses roughly 85 %&lt;/strong>: TPOT jumps from 35 ms to 80 ms in a few seconds because the engine starts competing for the HBM with its own evictions. Issue &lt;strong>vllm#35387&lt;/strong> documents another anomalous case: &lt;strong>MTP (speculative decoding) causing a 76 % latency regression&lt;/strong> on Qwen3-Next-80B-A3B-Instruct-FP8. The TPOT metric caught it before any client complaints were filed.&lt;/p>
&lt;p>&lt;strong>Operational implication.&lt;/strong> The difference from TTFT: if TTFT grows and Queue Time is stable, you are prefill bound; if TPOT grows at a steady rate, there is pressure on the HBM (KV cache pool or swap enabled). A secondary alert on the TPOT SLO, but also watch the &lt;strong>derivative&lt;/strong>: TPOT rising 1 ms every 10 minutes is a latent regression that does not break the SLO yet but will.&lt;/p>
&lt;h2 id="the-operational-rule-read-metrics-by-family-not-in-isolation">The operational rule: read metrics by family, not in isolation&lt;/h2>
&lt;div class="diagram" style="max-width:820px;margin:1.5rem auto;">
&lt;svg viewBox="0 0 820 280" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="combined reading of metrics by family">
&lt;style>.b{stroke:#333;stroke-width:1.4;rx:6}.c{fill:#dfe9f5;stroke:#356}.m{fill:#eef0d0;stroke:#7a3}.t{fill:#f4e3cf;stroke:#a63}.s{fill:#f6e2e2;stroke:#a33}.title{font:600 13px sans-serif;fill:#222}.h{font:700 12px sans-serif;fill:#222}.l{font:11px sans-serif;fill:#222}.n{font:italic 10px sans-serif;fill:#444}&lt;/style>
&lt;text x="410" y="20" text-anchor="middle" class="title">Combinations that diagnose (each family on its own misleads)&lt;/text>
&lt;rect x="20" y="40" width="380" height="100" class="b c"/>
&lt;text x="30" y="62" class="h">COMPUTE saturated BUT memory free&lt;/text>
&lt;text x="30" y="80" class="l">SM_OCCUPANCY 95% + TENSOR_ACTIVE 75% + DRAM_ACTIVE 50%&lt;/text>
&lt;text x="30" y="98" class="l">+ FB_USED 60%&lt;/text>
&lt;text x="30" y="120" class="n">→ Prefill bound. Lever: speculative decoding,&lt;/text>
&lt;text x="30" y="134" class="n"> chunked prefill, disaggregated serving.&lt;/text>
&lt;rect x="420" y="40" width="380" height="100" class="b m"/>
&lt;text x="430" y="62" class="h">MEMORY saturated BUT compute slack&lt;/text>
&lt;text x="430" y="80" class="l">SM_OCCUPANCY 35% + TENSOR_ACTIVE 18% + DRAM_ACTIVE 92%&lt;/text>
&lt;text x="430" y="98" class="l">+ gpu_cache_usage_perc 88%&lt;/text>
&lt;text x="430" y="120" class="n">→ Decode bound + KV cache under pressure.&lt;/text>
&lt;text x="430" y="134" class="n"> Lever: FP8 KV cache, shorter context.&lt;/text>
&lt;rect x="20" y="150" width="380" height="120" class="b t"/>
&lt;text x="30" y="172" class="h">High TPOT WITHOUT saturating compute or memory&lt;/text>
&lt;text x="30" y="190" class="l">DRAM_ACTIVE 65% + FB_USED 70% + temp 78°C&lt;/text>
&lt;text x="30" y="208" class="l">+ THROTTLE_REASONS = 0x40 (HW_THERMAL)&lt;/text>
&lt;text x="30" y="230" class="n">→ Silent thermal throttle.&lt;/text>
&lt;text x="30" y="244" class="n"> Lever: check rack ventilation, not the engine.&lt;/text>
&lt;text x="30" y="258" class="n"> Classic Dell/Lenovo KB case.&lt;/text>
&lt;rect x="420" y="150" width="380" height="120" class="b s"/>
&lt;text x="430" y="172" class="h">High TTFT P95 WITHOUT throttle or queue&lt;/text>
&lt;text x="430" y="190" class="l">num_requests_waiting 0 + throttle 0 + DRAM_ACTIVE 70%&lt;/text>
&lt;text x="430" y="208" class="l">+ v2/v1 ratio = 1.4 (canary live)&lt;/text>
&lt;text x="430" y="230" class="n">→ v2 model regression in prefill.&lt;/text>
&lt;text x="430" y="244" class="n"> Lever: roll back the canary,&lt;/text>
&lt;text x="430" y="258" class="n"> review the v2 engine config.&lt;/text>
&lt;/svg>
&lt;/div>
&lt;h2 id="three-anti-patterns-of-the-novice-operator">Three anti-patterns of the novice operator&lt;/h2>
&lt;p>&lt;strong>Anti-pattern 1 — alerting only on absolute thresholds.&lt;/strong> An H100 at 87 % FB is not necessarily an alarm; an H100 at 87 % rising 2 %/min is. Alerts that fire on a fixed threshold without looking at the derivative produce twice the noise and half the value. Rule: for metrics with known dynamics (KV cache, FB, queue), alert on a &lt;strong>sustained delta&lt;/strong>, not just on the level.&lt;/p>
&lt;p>&lt;strong>Anti-pattern 2 — confusing SBE with DBE.&lt;/strong> The &lt;code>DCGM_FI_DEV_ECC_SBE_VOL_TOTAL&lt;/code> counter (single-bit, correctable) grows &lt;strong>continuously&lt;/strong> on any HBM under load; it is not an alarm, it is physics. The one that matters is &lt;code>DCGM_FI_DEV_ECC_DBE_VOL_TOTAL&lt;/code> (double-bit, uncorrectable). Confusing them yields false negatives (not alerting on a real DBE) or false positives (alerting on a harmless SBE).&lt;/p>
&lt;p>&lt;strong>Anti-pattern 3 — treating SM_OCCUPANCY at 99 % as &amp;ldquo;saturated&amp;rdquo;.&lt;/strong> The LLM decode regime is memory-bound, not compute-bound; high SM occupancy with low TENSOR_ACTIVE and high DRAM_ACTIVE &lt;strong>is normal&lt;/strong>. Sizing for &amp;ldquo;GPU at 60 %&amp;rdquo; and asking for more hardware when the cluster is saturated in HBM (not in SMs) means buying twice the GPUs without gaining throughput. Rule: always read SM_OCCUPANCY together with TENSOR_ACTIVE and DRAM_ACTIVE; on its own it means nothing.&lt;/p>
&lt;h2 id="applied-to-typical-on-premise-hardware">Applied to typical on-premise hardware&lt;/h2>
&lt;p>For a generic cluster of &lt;strong>4 nodes × 4×H100 SXM 80 GB with intra-node NVLink&lt;/strong>:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>DCGM Exporter&lt;/strong> per node (a GPU Operator DaemonSet) emitting every 15 s; cardinality per GPU is around 80 series. A 16-GPU cluster gives roughly 1.3k base series, around 85k samples/min with a 15 s scrape.&lt;/li>
&lt;li>&lt;strong>vLLM /metrics&lt;/strong> per inference pod; each replica emits around 50 base series. For 16 replicas, around 800 additional series, around 3k samples/min.&lt;/li>
&lt;li>&lt;strong>Prometheus retention&lt;/strong>: 30 days at high resolution plus 1 year downsampled through a Thanos sidecar or Mimir. Estimated volume: 25–35 GB/day.&lt;/li>
&lt;li>&lt;strong>Alertmanager&lt;/strong>: the 6 critical alerts from the &lt;a href="https://blog.lo0.es/en/posts/gpu-observability-llm-inference-twelve-dcgm-vllm-metrics/">previous post&lt;/a> plus derived alerts (delta, v2/v1 ratio, decoded throttle bitmap).&lt;/li>
&lt;/ul>
&lt;p>Each metric is worth exposing also as an &lt;strong>OTel attribute&lt;/strong> on the spans of the &lt;a href="https://blog.lo0.es/en/posts/llm-tracing-opentelemetry-genai/">GenAI tracing&lt;/a>: &lt;code>gpu.fb_used_pct&lt;/code>, &lt;code>gpu.dram_active&lt;/code>, &lt;code>gpu.throttle_reasons.decoded&lt;/code>. That lets you correlate a slow request with the state of the GPU at that instant, without jumping between dashboards.&lt;/p>
&lt;h2 id="what-we-have-not-covered-upcoming-posts">What we have not covered (upcoming posts)&lt;/h2>
&lt;ul>
&lt;li>&lt;strong>Per-alert runbooks&lt;/strong> — the translation of each anomalous metric into concrete action (drain, reset, RMA, scaling, rollback) in the next post: &lt;a href="https://blog.lo0.es/en/posts/incident-response-runbooks-llm-inference-kafka-keep/">Incident response runbooks&lt;/a>.&lt;/li>
&lt;li>&lt;strong>Tail sampling for metric ↔ trace correlation&lt;/strong> — what is preserved when an alert fires, for post-mortem investigation.&lt;/li>
&lt;li>&lt;strong>Per-tenant showback&lt;/strong> combining &lt;code>vllm:request_success_total&lt;/code> × &lt;code>gen_ai.usage.*&lt;/code> × &lt;code>DCGM_FI_DEV_POWER_USAGE&lt;/code> to bill real energy cost.&lt;/li>
&lt;li>&lt;strong>Multi-tenant fairness metrics&lt;/strong> — when a tenant hogs the KV cache pool and how to detect it.&lt;/li>
&lt;/ul>
&lt;h2 id="see-also">See also&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/gpu-observability-llm-inference-twelve-dcgm-vllm-metrics/">GPU observability for LLM inference&lt;/a> — the compact list that this post goes deeper into.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/incident-response-runbooks-llm-inference-kafka-keep/">Incident response runbooks for LLM with Keep + Kafka&lt;/a> — the translation of each anomaly into action.&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 other half of observability.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/llm-inference-autoscaling-kubernetes-hpa-keda/">Autoscaling LLM on Kubernetes&lt;/a> — &lt;code>num_requests_waiting&lt;/code> and &lt;code>gpu_cache_usage_perc&lt;/code> as primary HPA metrics.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/capacity-planning-on-premise-llm-inference/">Capacity planning for on-premise LLM inference&lt;/a> — how the thresholds relate to the sizing.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/canary-blue-green-shadow-llm-models/">Canary, blue-green and shadow&lt;/a> — the v2/v1 TTFT ratio as a gate.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/continuous-batching-orca-vllm-chunked-prefill-goodput/">Continuous batching&lt;/a> — explains preempt-on-OOM and the KV pool sawtooth.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/kv-cache-working-memory-llm-inference/">KV cache&lt;/a> — underpins the calculation of &lt;code>gpu_cache_usage_perc&lt;/code>.&lt;/li>
&lt;/ul>
&lt;h2 id="references">References&lt;/h2>
&lt;ul>
&lt;li>Meta — &lt;em>Faulty Nvidia H100 GPUs and HBM3 memory caused half of failures during Llama 3 training&lt;/em> (Tom&amp;rsquo;s Hardware, 2024). &lt;a href="https://www.tomshardware.com/tech-industry/artificial-intelligence/faulty-nvidia-h100-gpus-and-hbm3-memory-caused-half-of-the-failures-during-llama-3-training-one-failure-every-three-hours-for-metas-16384-gpu-training-cluster">tomshardware.com&lt;/a>&lt;/li>
&lt;li>&lt;em>Story of Two GPUs: Characterizing the Resilience of Hopper H100 and Ampere A100&lt;/em>. arXiv 2503.11901. &lt;a href="https://arxiv.org/html/2503.11901v3">https://arxiv.org/html/2503.11901v3&lt;/a>&lt;/li>
&lt;li>ByteDance — &lt;em>Robust LLM Training Infrastructure at ByteDance&lt;/em>. arXiv 2509.16293. &lt;a href="https://arxiv.org/pdf/2509.16293">https://arxiv.org/pdf/2509.16293&lt;/a>&lt;/li>
&lt;li>&lt;em>Mind the Memory Gap: Unveiling GPU Bottlenecks in Large-Batch LLM Inference&lt;/em>. arXiv 2503.08311.&lt;/li>
&lt;li>&lt;em>Capacity-Aware Inference: Mitigating the Straggler Effect in Mixture of Experts&lt;/em>. arXiv 2503.05066.&lt;/li>
&lt;li>NVIDIA — &lt;em>Analyzing Xid Errors with the Xid Catalog&lt;/em> and &lt;em>Memory Error Management&lt;/em> (docs.nvidia.com/deploy).&lt;/li>
&lt;li>Dell — &lt;em>PowerEdge XE8640 with H100 - GPU Performance Issue HW Power Brake Slowdown - Active&lt;/em> (KB 000220508).&lt;/li>
&lt;li>Lenovo — &lt;em>Power brake reporting on H100 GPU&lt;/em> (HT514380).&lt;/li>
&lt;li>vLLM project — issues #5051 (preempted metric), #7604 and #25677 (chunked prefill regression), #11912 (long-prompt regression), #16300 (TP=8 worse than TP=4), #16985 (long-running degradation), #20783 (compressed-tensors no speedup), #35387 (MTP regression).&lt;/li>
&lt;li>Red Hat — &lt;em>5 steps to triage vLLM performance&lt;/em>. &lt;a href="https://developers.redhat.com/articles/2026/03/09/5-steps-triage-vllm-performance">https://developers.redhat.com/articles/2026/03/09/5-steps-triage-vllm-performance&lt;/a>&lt;/li>
&lt;li>AI21 — &lt;em>Go big or go OOM: the art of scaling vLLM&lt;/em>. &lt;a href="https://www.ai21.com/blog/scaling-vllm-without-oom/">https://www.ai21.com/blog/scaling-vllm-without-oom/&lt;/a>&lt;/li>
&lt;li>&lt;em>11-Second Time to First Token on a Healthy vLLM Server&lt;/em> (Medium, Ingero, 2026).&lt;/li>
&lt;li>NVIDIA — &lt;em>DGX SuperPOD Electrical Specifications&lt;/em> (docs.nvidia.com/dgx-superpod).&lt;/li>
&lt;/ul>
&lt;p>Sources: the full URLs are linked inline on each reference.&lt;/p></description></item><item><title>GPU observability for LLM inference: the twelve DCGM and vLLM metrics that dictate the health of your production</title><link>https://blog.lo0.es/en/posts/gpu-observability-llm-inference-twelve-dcgm-vllm-metrics/</link><pubDate>Mon, 01 Jun 2026 15:30:00 +0200</pubDate><guid>https://blog.lo0.es/en/posts/gpu-observability-llm-inference-twelve-dcgm-vllm-metrics/</guid><description>&lt;blockquote>
&lt;p>This post complements those on &lt;a href="https://blog.lo0.es/en/posts/llm-tracing-opentelemetry-genai/">LLM tracing with OpenTelemetry GenAI&lt;/a> (the tracing layer above the metrics), &lt;a href="https://blog.lo0.es/en/posts/capacity-planning-on-premise-llm-inference/">Capacity planning&lt;/a> (what was sized and what must be watched) and &lt;a href="https://blog.lo0.es/en/posts/continuous-batching-orca-vllm-chunked-prefill-goodput/">Continuous batching&lt;/a> (the mechanism that explains several of the engine metrics).&lt;/p>
&lt;/blockquote>
&lt;h2 id="tldr">TL;DR&lt;/h2>
&lt;p>Observability for an LLM inference cluster is built on &lt;strong>two complementary sources&lt;/strong>: the GPU hardware metrics exposed by the &lt;strong>DCGM (Data Center GPU Manager) Exporter&lt;/strong>, part of the NVIDIA GPU Operator, and the metrics from the &lt;strong>inference engine&lt;/strong> (vLLM, SGLang, TensorRT-LLM) exposed on a Prometheus-compatible &lt;code>/metrics&lt;/code>. Neither is enough on its own. The classic &lt;code>nvidia-smi&lt;/code> metric called &lt;em>GPU utilization&lt;/em> is misleading for LLMs: it reads high whenever &lt;strong>any kernel&lt;/strong> is running, without distinguishing tensor cores burning from SMs waiting on HBM. The full cockpit has &lt;strong>twelve DCGM metrics in four families&lt;/strong> (compute: &lt;code>DCGM_FI_PROF_SM_OCCUPANCY&lt;/code>, &lt;code>DCGM_FI_PROF_PIPE_TENSOR_ACTIVE&lt;/code>, &lt;code>DCGM_FI_PROF_DRAM_ACTIVE&lt;/code>; memory: &lt;code>DCGM_FI_DEV_FB_USED&lt;/code>, &lt;code>DCGM_FI_DEV_FB_FREE&lt;/code>, &lt;code>DCGM_FI_DEV_NVLINK_BANDWIDTH_TOTAL&lt;/code>; thermal-power: &lt;code>DCGM_FI_DEV_GPU_TEMP&lt;/code>, &lt;code>DCGM_FI_DEV_POWER_USAGE&lt;/code>, &lt;code>DCGM_FI_DEV_CLOCK_THROTTLE_REASONS&lt;/code>; health: &lt;code>DCGM_FI_DEV_XID_ERRORS&lt;/code>, &lt;code>DCGM_FI_DEV_ECC_DBE_VOL_TOTAL&lt;/code>, &lt;code>DCGM_FI_DEV_RETIRED_DBE&lt;/code>) and &lt;strong>five vLLM engine metrics&lt;/strong> (&lt;code>vllm:num_requests_running&lt;/code>, &lt;code>vllm:num_requests_waiting&lt;/code>, &lt;code>vllm:gpu_cache_usage_perc&lt;/code>, &lt;code>vllm:time_to_first_token_seconds&lt;/code>, &lt;code>vllm:time_per_output_token_seconds&lt;/code>). Each one has a defensible green/amber/red threshold, a PromQL query for alerting, and at least one common false reading that confuses the junior operator. The &lt;strong>six critical alerts&lt;/strong> that any production cluster must fire are: HBM &amp;gt; 92 %, thermal or power throttle, XID error, ECC double-bit, KV cache pool &amp;gt; 95 %, and TTFT P95 outside SLO for 5 minutes. The point of having this panel: that the operator on shift diagnoses the origin of a degradation in &lt;strong>under five minutes&lt;/strong>, without opening an SSH console to the GPUs. When that holds, the cluster has moved to professional operation; until then, it is operated on intuition.&lt;/p>
&lt;h2 id="you-are-here-observe-the-other-half-of-tracing">You are here: OBSERVE (the other half of tracing)&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;style>.box{stroke:#444;stroke-width:1.4;rx:6}.active{fill:#c9a8e9;stroke-width:3}.idle{fill:#f4f4f4}.lbl{font:600 12px sans-serif;fill:#222}.arr{stroke:#666;stroke-width:1.4;fill:none;marker-end:url(#obm)}.cyc{stroke:#888;stroke-width:1.2;fill:none;stroke-dasharray:4 2;marker-end:url(#obm)}&lt;/style>
&lt;defs>&lt;marker id="obm" 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" class="lbl">You are here: OBSERVE · metrics (DCGM + engine) complement tracing&lt;/text>
&lt;rect x="30" y="35" width="110" height="35" class="box idle"/>&lt;text x="85" y="58" text-anchor="middle" class="lbl">1 · Data&lt;/text>
&lt;rect x="155" y="35" width="110" height="35" class="box idle"/>&lt;text x="210" y="58" text-anchor="middle" class="lbl">2 · Tune&lt;/text>
&lt;rect x="280" y="35" width="110" height="35" class="box idle"/>&lt;text x="335" y="58" text-anchor="middle" class="lbl">3 · Eval&lt;/text>
&lt;rect x="405" y="35" width="110" height="35" class="box idle"/>&lt;text x="460" y="58" text-anchor="middle" class="lbl">4 · Deploy&lt;/text>
&lt;rect x="530" y="35" width="110" height="35" class="box active"/>&lt;text x="585" y="58" text-anchor="middle" class="lbl">5 · Observe&lt;/text>
&lt;rect x="655" y="35" width="110" height="35" class="box idle"/>&lt;text x="710" y="58" text-anchor="middle" class="lbl">6 · Retrain&lt;/text>
&lt;path class="arr" d="M140,52 L155,52"/>&lt;path class="arr" d="M265,52 L280,52"/>&lt;path class="arr" d="M390,52 L405,52"/>&lt;path class="arr" d="M515,52 L530,52"/>&lt;path class="arr" d="M640,52 L655,52"/>
&lt;path class="cyc" d="M710,72 L710,82 L85,82 L85,72"/>
&lt;/svg>
&lt;/div>
&lt;p>Tracing, already covered in &lt;a href="https://blog.lo0.es/en/posts/llm-tracing-opentelemetry-genai/">LLM tracing with OpenTelemetry GenAI&lt;/a>, answers &lt;em>what happened in this specific request&lt;/em>. Metrics answer &lt;em>what is happening across the cluster in aggregate&lt;/em>. They are complementary: an alert on the metrics side tells you &amp;ldquo;the cluster is degrading&amp;rdquo;, tracing tells you &amp;ldquo;and here is the specific trace that proves it&amp;rdquo;. A cluster without tracing but with metrics operates; a cluster without metrics but with tracing &lt;strong>does not operate, it debugs&lt;/strong>.&lt;/p>
&lt;h2 id="the-analogy-the-cockpit-of-a-modern-aircraft">The analogy: the cockpit of a modern aircraft&lt;/h2>
&lt;p>In a modern commercial aircraft, the pilot&amp;rsquo;s instrument panel has more than 70 active indicators. If there were only one, the altimeter, say, the aircraft would fly into the ground at the first moment of low visibility. You need the altimeter &lt;strong>and&lt;/strong> the attitude indicator, &lt;strong>and&lt;/strong> the airspeed indicator, &lt;strong>and&lt;/strong> the turn indicator, &lt;strong>and&lt;/strong> the fuel gauge, &lt;strong>and&lt;/strong> the oil pressure gauges for each engine, &lt;strong>and&lt;/strong> the turbine exit temperatures. Each answers a different question. And together they cover the operational question: &lt;em>is the aircraft healthy, is it where it should be, and is it going where we want?&lt;/em>&lt;/p>
&lt;p>Observability for an LLM inference cluster works the same way. A single metric, &amp;ldquo;GPU utilization 99 %&amp;rdquo;, answers nothing useful. It is like looking only at the car&amp;rsquo;s odometer to diagnose why the engine is making a noise. The full cockpit is &lt;strong>twelve instruments on the hardware side plus five on the inference engine side&lt;/strong>, organised into families that answer different questions:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Compute and efficiency&lt;/strong>: &lt;em>are the tensor cores doing the work we expect, or are they waiting?&lt;/em>&lt;/li>
&lt;li>&lt;strong>Memory&lt;/strong>: &lt;em>is there VRAM left for new requests, or are we on the edge of OOM?&lt;/em>&lt;/li>
&lt;li>&lt;strong>Thermal and power&lt;/strong>: &lt;em>is the hardware healthy, or is it silently limiting throughput?&lt;/em>&lt;/li>
&lt;li>&lt;strong>Health and errors&lt;/strong>: &lt;em>is there hardware degradation under way (ECC, XID, NVLink)?&lt;/em>&lt;/li>
&lt;li>&lt;strong>Inference engine&lt;/strong>: &lt;em>is the queue growing, is the KV pool saturated, is the SLO being met?&lt;/em>&lt;/li>
&lt;/ul>
&lt;p>The first four answer &amp;ldquo;is the GPU fine?&amp;rdquo;. The fifth answers &amp;ldquo;is it delivering the service we promised?&amp;rdquo;. The two questions are different and both must have an answer at a glance.&lt;/p>
&lt;h2 id="why-nvidia-smi-gpu-util-misleads-with-llms">Why &lt;code>nvidia-smi&lt;/code> &lt;code>GPU-Util&lt;/code> misleads with LLMs&lt;/h2>
&lt;p>The classic metric that appears in &lt;code>nvidia-smi&lt;/code> as &lt;code>GPU-Util&lt;/code> corresponds to &lt;code>DCGM_FI_DEV_GPU_UTIL&lt;/code>. Its official definition: &amp;ldquo;percentage of time during which one or more kernels were executing on the GPU&amp;rdquo;. The problem with LLMs: the decode phase is &lt;strong>memory-bound&lt;/strong>, not compute-bound. When the inference engine decodes token by token, the GPU spends 90 % of the time waiting for the HBM to finish delivering the model weights and the KV cache. There is a kernel running (the HBM read), so &lt;code>GPU-Util&lt;/code> reports values close to 100 %. But the tensor cores are idle: the bottleneck is memory, not compute.&lt;/p>
&lt;p>Practical result: the operator sees &amp;ldquo;GPU-Util 99 %&amp;rdquo; in Grafana and assumes &amp;ldquo;GPU saturated, no more load can go in&amp;rdquo;. But the reality may be &amp;ldquo;compute at 25 %, HBM saturated at 95 %&amp;rdquo;, which changes the operational decisions (quantisation, batch size, parallelism). The classic metric lies by oversimplifying.&lt;/p>
&lt;p>The right move is to look at the &lt;strong>three DCGM profiling metrics&lt;/strong> of the &lt;code>_FI_PROF_*&lt;/code> subsystem:&lt;/p>
&lt;ul>
&lt;li>&lt;code>DCGM_FI_PROF_SM_OCCUPANCY&lt;/code>: ratio of active warps over the maximum per SM. &lt;em>Is there parallel work?&lt;/em>&lt;/li>
&lt;li>&lt;code>DCGM_FI_PROF_PIPE_TENSOR_ACTIVE&lt;/code>: % of cycles with tensor cores effectively active. &lt;em>Is compute working?&lt;/em>&lt;/li>
&lt;li>&lt;code>DCGM_FI_PROF_DRAM_ACTIVE&lt;/code>: % of cycles with HBM transferring. &lt;em>Is memory saturated?&lt;/em>&lt;/li>
&lt;/ul>
&lt;p>A typical decode-bound GPU running Llama 70B on an H100 shows: SM occupancy 35–55 %, tensor active 15–30 %, DRAM active 80–95 %. That is the real &amp;ldquo;saturated GPU&amp;rdquo; for LLMs. The three together tell the regimes apart; none of them alone says anything actionable.&lt;/p>
&lt;h2 id="how-they-are-wired-up-in-production">How they are wired up in production&lt;/h2>
&lt;p>The platform side is covered in &lt;a href="https://blog.lo0.es/en/posts/five-maturity-levels-platform-under-llm/">Five maturity levels&lt;/a> (level 4, the GPU plane) and &lt;a href="https://blog.lo0.es/en/posts/greenfield-deployment-phases-on-premise-llm-platform/">Seven deployment phases&lt;/a> (phase F5). For the observer, the key pieces are:&lt;/p>
&lt;p>&lt;strong>NVIDIA GPU Operator.&lt;/strong> Helm manifests that deploy on each GPU node: drivers, container toolkit, MIG manager and &lt;strong>DCGM Exporter&lt;/strong>. The last one exposes &lt;code>/metrics&lt;/code> in Prometheus format with all the &lt;code>DCGM_FI_*&lt;/code> listed above. It is scraped from the cluster&amp;rsquo;s internal Prometheus.&lt;/p>
&lt;p>&lt;strong>Inference engine.&lt;/strong> vLLM exposes &lt;code>/metrics&lt;/code> on port 8000 (default) with &lt;code>vllm:*&lt;/code> metrics. SGLang exposes it too, with the &lt;code>sglang:&lt;/code> prefix. TensorRT-LLM exposes it through Triton Inference Server with the &lt;code>nv_inference:&lt;/code> prefix. The basic naming convention is similar across the three engines; the thresholds and queries in this post assume vLLM, but they translate.&lt;/p>
&lt;p>&lt;strong>ServiceMonitor / PodMonitor.&lt;/strong> The Prometheus operator resource that says what to scrape. Minimal example:&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">apiVersion&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">monitoring.coreos.com/v1&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">kind&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">PodMonitor&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">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">vllm-inference&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">spec&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">selector&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">matchLabels&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>{&lt;span class="w"> &lt;/span>&lt;span class="nt">app&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">vllm }&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">podMetricsEndpoints&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">port&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">metrics&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">interval&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">15s&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;strong>Dashboards.&lt;/strong> The NVIDIA operator publishes reference Grafana dashboards for DCGM in &lt;code>nvidia/dcgm-exporter&lt;/code> (the official repo). vLLM publishes one in &lt;code>vllm-project/vllm&lt;/code> (the &lt;code>examples/&lt;/code> folder). Both work as a base; each team adds the panels specific to its own SLO.&lt;/p>
&lt;h2 id="the-twelve-dcgm-metrics-organised-by-family">The twelve DCGM metrics organised by family&lt;/h2>
&lt;div class="diagram" style="max-width:820px;margin:1.5rem auto;">
&lt;svg viewBox="0 0 820 360" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="twelve DCGM metrics in four families">
&lt;style>.b{stroke:#333;stroke-width:1.4;rx:6}.fc{fill:#dfe9f5;stroke:#356}.fm{fill:#eef0d0;stroke:#7a3}.ft{fill:#f4e3cf;stroke:#a63}.fs{fill:#f6e2e2;stroke:#a33}.title{font:600 13px sans-serif;fill:#222}.fam{font:700 11px sans-serif;fill:#222}.met{font:10px monospace;fill:#222}.note{font:italic 10px sans-serif;fill:#555}&lt;/style>
&lt;text x="410" y="20" text-anchor="middle" class="title">DCGM cockpit: 12 metrics in 4 families&lt;/text>
&lt;rect x="20" y="40" width="195" height="290" class="b fc"/>
&lt;text x="117" y="60" text-anchor="middle" class="fam">COMPUTE&lt;/text>
&lt;text x="30" y="90" class="met">DCGM_FI_PROF_&lt;/text>&lt;text x="30" y="105" class="met">SM_OCCUPANCY&lt;/text>
&lt;text x="30" y="135" class="met">DCGM_FI_PROF_&lt;/text>&lt;text x="30" y="150" class="met">PIPE_TENSOR_ACTIVE&lt;/text>
&lt;text x="30" y="180" class="met">DCGM_FI_PROF_&lt;/text>&lt;text x="30" y="195" class="met">DRAM_ACTIVE&lt;/text>
&lt;text x="30" y="240" text-anchor="start" class="note">Is compute working or&lt;/text>
&lt;text x="30" y="254" text-anchor="start" class="note">waiting on HBM?&lt;/text>
&lt;rect x="220" y="40" width="195" height="290" class="b fm"/>
&lt;text x="317" y="60" text-anchor="middle" class="fam">MEMORY&lt;/text>
&lt;text x="230" y="90" class="met">DCGM_FI_DEV_&lt;/text>&lt;text x="230" y="105" class="met">FB_USED&lt;/text>
&lt;text x="230" y="135" class="met">DCGM_FI_DEV_&lt;/text>&lt;text x="230" y="150" class="met">FB_FREE&lt;/text>
&lt;text x="230" y="180" class="met">DCGM_FI_DEV_NVLINK_&lt;/text>&lt;text x="230" y="195" class="met">BANDWIDTH_TOTAL&lt;/text>
&lt;text x="230" y="240" class="note">Is there VRAM left for&lt;/text>
&lt;text x="230" y="254" class="note">new requests?&lt;/text>
&lt;rect x="420" y="40" width="195" height="290" class="b ft"/>
&lt;text x="517" y="60" text-anchor="middle" class="fam">THERMAL · POWER&lt;/text>
&lt;text x="430" y="90" class="met">DCGM_FI_DEV_&lt;/text>&lt;text x="430" y="105" class="met">GPU_TEMP&lt;/text>
&lt;text x="430" y="135" class="met">DCGM_FI_DEV_&lt;/text>&lt;text x="430" y="150" class="met">POWER_USAGE&lt;/text>
&lt;text x="430" y="180" class="met">DCGM_FI_DEV_CLOCK_&lt;/text>&lt;text x="430" y="195" class="met">THROTTLE_REASONS&lt;/text>
&lt;text x="430" y="240" class="note">Healthy hardware or&lt;/text>
&lt;text x="430" y="254" class="note">silently limiting?&lt;/text>
&lt;rect x="620" y="40" width="180" height="290" class="b fs"/>
&lt;text x="710" y="60" text-anchor="middle" class="fam">HEALTH&lt;/text>
&lt;text x="630" y="90" class="met">DCGM_FI_DEV_&lt;/text>&lt;text x="630" y="105" class="met">XID_ERRORS&lt;/text>
&lt;text x="630" y="135" class="met">DCGM_FI_DEV_&lt;/text>&lt;text x="630" y="150" class="met">ECC_DBE_VOL_TOTAL&lt;/text>
&lt;text x="630" y="180" class="met">DCGM_FI_DEV_&lt;/text>&lt;text x="630" y="195" class="met">RETIRED_DBE&lt;/text>
&lt;text x="630" y="240" class="note">Is the silicon&lt;/text>
&lt;text x="630" y="254" class="note">degrading right now?&lt;/text>
&lt;text x="410" y="350" text-anchor="middle" class="note">Each family answers a different question · none suffices alone&lt;/text>
&lt;/svg>
&lt;/div>
&lt;h3 id="family-1--compute">Family 1 — Compute&lt;/h3>
&lt;p>&lt;strong>&lt;code>DCGM_FI_PROF_SM_OCCUPANCY&lt;/code>&lt;/strong>: ratio of active warps per SM over the maximum possible. Value between 0 and 1.&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Green&lt;/strong>: 0.30–0.70 (typical LLM decode regime).&lt;/li>
&lt;li>&lt;strong>Amber&lt;/strong>: &amp;lt; 0.20 sustained (batch too small, GPU under-used in parallelism).&lt;/li>
&lt;li>&lt;strong>Red&lt;/strong>: 0.95 sustained with low DRAM_ACTIVE (pathological kernel saturating the SMs).&lt;/li>
&lt;/ul>
&lt;p>&lt;strong>&lt;code>DCGM_FI_PROF_PIPE_TENSOR_ACTIVE&lt;/code>&lt;/strong>: % of cycles with tensor cores executing. The key metric for &amp;ldquo;is compute producing?&amp;rdquo;.&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Green in prefill&lt;/strong>: 50–80 %.&lt;/li>
&lt;li>&lt;strong>Green in decode&lt;/strong>: 15–30 % (decode is memory-bound, this is not a symptom of a problem).&lt;/li>
&lt;li>&lt;strong>Red&lt;/strong>: &amp;lt; 5 % sustained in prefill, or the engine is not using the tensor cores at all (bad config, incompatible format).&lt;/li>
&lt;/ul>
&lt;p>&lt;strong>&lt;code>DCGM_FI_PROF_DRAM_ACTIVE&lt;/code>&lt;/strong>: % of cycles with HBM transferring data. The key metric for detecting memory saturation.&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Green in decode&lt;/strong>: 60–85 %.&lt;/li>
&lt;li>&lt;strong>Amber&lt;/strong>: &amp;gt; 90 % sustained (HBM is a firm bottleneck, which explains high TPOT).&lt;/li>
&lt;li>&lt;strong>Red&lt;/strong>: &amp;gt; 95 % sustained with KV cache pool &amp;lt; 70 % (something other than the engine is asking for HBM; investigate leaks).&lt;/li>
&lt;/ul>
&lt;h3 id="family-2--memory">Family 2 — Memory&lt;/h3>
&lt;p>&lt;strong>&lt;code>DCGM_FI_DEV_FB_USED&lt;/code>&lt;/strong>: Frame Buffer (HBM) used, in MiB.&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Green&lt;/strong>: 70–85 % of total.&lt;/li>
&lt;li>&lt;strong>Amber&lt;/strong>: 86–92 %.&lt;/li>
&lt;li>&lt;strong>Red&lt;/strong>: &amp;gt; 92 % (risk of OOM on the next paged-attention allocation).&lt;/li>
&lt;/ul>
&lt;p>PromQL for the cluster-wide percentage: &lt;code>100 * sum(DCGM_FI_DEV_FB_USED) / sum(DCGM_FI_DEV_FB_TOTAL)&lt;/code>.&lt;/p>
&lt;p>&lt;strong>&lt;code>DCGM_FI_DEV_FB_FREE&lt;/code>&lt;/strong>: free Frame Buffer. Complementary to the previous one; useful for absolute alerts (&lt;code>&amp;lt; 4096 MiB free&lt;/code>).&lt;/p>
&lt;p>&lt;strong>&lt;code>DCGM_FI_DEV_NVLINK_BANDWIDTH_TOTAL&lt;/code>&lt;/strong>: aggregate NVLink bandwidth in MB/s. For TP (tensor parallel) topologies that cross GPUs over NVLink, this metric reveals whether the parallelism split is saturating the bus.&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Green&lt;/strong>: varies with topology. On 4×H100 SXM with NVLink 4.0, theoretical capacity is 450 GB/s per GPU. A typical TP=4 regime: 50–150 GB/s sustained.&lt;/li>
&lt;li>&lt;strong>Red&lt;/strong>: &amp;gt; 90 % of capacity sustained (check whether the model would fit with lower TP or with pipeline parallel).&lt;/li>
&lt;/ul>
&lt;h3 id="family-3--thermal-and-power">Family 3 — Thermal and power&lt;/h3>
&lt;p>&lt;strong>&lt;code>DCGM_FI_DEV_GPU_TEMP&lt;/code>&lt;/strong>: die temperature in °C.&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Green&lt;/strong>: &amp;lt; 75 °C.&lt;/li>
&lt;li>&lt;strong>Amber&lt;/strong>: 75–82 °C.&lt;/li>
&lt;li>&lt;strong>Red&lt;/strong>: &amp;gt; 83 °C (close to the H100&amp;rsquo;s automatic thermal throttle; check ventilation, air flow, rack inlet temperature).&lt;/li>
&lt;/ul>
&lt;p>&lt;strong>&lt;code>DCGM_FI_DEV_POWER_USAGE&lt;/code>&lt;/strong>: draw in watts. For the H100 SXM, nominal TDP is 700 W. Useful for three things: spotting an unusually low workload (suspect idle or a stall), billing real energy cost, and firing alerts if the draw approaches the PDU limit.&lt;/p>
&lt;p>&lt;strong>&lt;code>DCGM_FI_DEV_CLOCK_THROTTLE_REASONS&lt;/code>&lt;/strong>: encoded bitmap with the active throttle reasons. This is the metric that &lt;strong>silently explains&lt;/strong> TPOT degradations.&lt;/p>
&lt;p>Relevant bits:&lt;/p>
&lt;ul>
&lt;li>&lt;code>0x0000000000000001&lt;/code>: Idle (not a problem).&lt;/li>
&lt;li>&lt;code>0x0000000000000002&lt;/code>: App clocks setting.&lt;/li>
&lt;li>&lt;code>0x0000000000000004&lt;/code>: SW Power Cap (software limit, e.g. from &lt;code>nvidia-smi -pl&lt;/code>).&lt;/li>
&lt;li>&lt;code>0x0000000000000008&lt;/code>: HW Slowdown.&lt;/li>
&lt;li>&lt;code>0x0000000000000010&lt;/code>: Sync Boost (NVIDIA Sync).&lt;/li>
&lt;li>&lt;code>0x0000000000000020&lt;/code>: SW Thermal Slowdown (software thermal limit).&lt;/li>
&lt;li>&lt;code>0x0000000000000040&lt;/code>: HW Thermal Slowdown (hardware thermal limit, an emergency).&lt;/li>
&lt;li>&lt;code>0x0000000000000080&lt;/code>: HW Power Brake Slowdown (PSU voltage drop).&lt;/li>
&lt;li>&lt;code>0x0000000000000100&lt;/code>: Display Clock Setting.&lt;/li>
&lt;/ul>
&lt;p>Any throttle other than &lt;code>Idle&lt;/code> with a sustained value &amp;gt; 0 &lt;strong>is an alert&lt;/strong>. TPOT degradation with &lt;code>DRAM_ACTIVE&lt;/code> already high and thermal throttle active is the classic &amp;ldquo;the rack is badly ventilated, it is not the engine&amp;rsquo;s fault&amp;rdquo;.&lt;/p>
&lt;h3 id="family-4--health">Family 4 — Health&lt;/h3>
&lt;p>&lt;strong>&lt;code>DCGM_FI_DEV_XID_ERRORS&lt;/code>&lt;/strong>: cumulative counter of driver XID errors. XIDs are critical event codes that NVIDIA documents exhaustively (XID 13: graphics engine exception; XID 31: GPU memory page fault; XID 43: reset channel verif error; XID 79: GPU has fallen off the bus; XID 95: uncontained ECC error; and so on). &lt;strong>Any increment is an immediate alert&lt;/strong>: many XIDs require a node reset or an RMA of the GPU.&lt;/p>
&lt;p>&lt;strong>&lt;code>DCGM_FI_DEV_ECC_DBE_VOL_TOTAL&lt;/code>&lt;/strong>: volatile double-bit ECC errors (uncorrectable). Unlike single-bit ones (which ECC corrects silently and which are counted in &lt;code>DCGM_FI_DEV_ECC_SBE_*&lt;/code>), double-bit errors &lt;strong>corrupt data&lt;/strong>. Any value &amp;gt; 0 is a critical alert: the GPU must be drained and inspected.&lt;/p>
&lt;p>&lt;strong>&lt;code>DCGM_FI_DEV_RETIRED_DBE&lt;/code>&lt;/strong>: physical HBM pages retired because of accumulated double-bit errors. NVIDIA retires faulty pages automatically to prevent future corruption. More than 4–8 retired pages on one GPU suggests silicon degradation: document it and plan a replacement in the next maintenance window.&lt;/p>
&lt;h2 id="the-five-inference-engine-metrics-vllm">The five inference engine metrics (vLLM)&lt;/h2>
&lt;p>The DCGM metrics answer &amp;ldquo;is the GPU healthy?&amp;rdquo;. The engine metrics answer &amp;ldquo;is the service meeting the SLO?&amp;rdquo;. Without them, you know the hardware works but you do not know whether the clients are happy.&lt;/p>
&lt;p>&lt;strong>&lt;code>vllm:num_requests_running&lt;/code>&lt;/strong>: requests currently in the batch. If it reaches the configured &lt;code>--max-num-seqs&lt;/code> and does not come down, the engine is saturated on concurrency (check VRAM and rebalance through the autoscaler, see &lt;a href="https://blog.lo0.es/en/posts/llm-inference-autoscaling-kubernetes-hpa-keda/">Autoscaling LLM on Kubernetes&lt;/a>).&lt;/p>
&lt;p>&lt;strong>&lt;code>vllm:num_requests_waiting&lt;/code>&lt;/strong>: requests queued, not yet in the batch. Any value &amp;gt; 0 sustained over minutes means the cluster is not scaling with the load. &lt;strong>This is the primary metric for HPA&lt;/strong>.&lt;/p>
&lt;p>&lt;strong>&lt;code>vllm:gpu_cache_usage_perc&lt;/code>&lt;/strong>: % of the KV cache pool in use.&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Green&lt;/strong>: 50–80 %.&lt;/li>
&lt;li>&lt;strong>Amber&lt;/strong>: 80–92 %.&lt;/li>
&lt;li>&lt;strong>Red&lt;/strong>: &amp;gt; 92 % (risk of &lt;strong>preempt-on-OOM&lt;/strong>: vLLM will drop requests to free memory, which raises TTFT visibly).&lt;/li>
&lt;/ul>
&lt;p>&lt;strong>&lt;code>vllm:time_to_first_token_seconds&lt;/code>&lt;/strong>: histogram of TTFT per request. Consumed as &lt;code>histogram_quantile(0.95, sum by(le)(rate(vllm:time_to_first_token_seconds_bucket[5m])))&lt;/code>. Compared against the TTFT P95 SLO, it fires the primary service alert.&lt;/p>
&lt;p>&lt;strong>&lt;code>vllm:time_per_output_token_seconds&lt;/code>&lt;/strong>: histogram of TPOT. Equivalent to the previous one but for streaming smoothness. Compared against the TPOT P95 SLO, it fires the secondary alert.&lt;/p>
&lt;h2 id="the-six-alerts-that-must-page-in-production">The six alerts that must page in production&lt;/h2>
&lt;p>Any serious production cluster fires these six alerts to a channel with an on-call rotation. Without them, the SLO is met by luck, not by process.&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">groups&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">gpu-llm-critical&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">rules&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">alert&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">GpuHbmNearOom&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">expr&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">100&lt;/span>&lt;span class="w"> &lt;/span>*&lt;span class="w"> &lt;/span>&lt;span class="l">(DCGM_FI_DEV_FB_USED / DCGM_FI_DEV_FB_TOTAL) &amp;gt; 92&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">for&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">2m&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">labels&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>{&lt;span class="w"> &lt;/span>&lt;span class="nt">severity&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">critical }&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">annotations&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">summary&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;HBM on {{ $labels.gpu }} at {{ $value }}% — OOM risk&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>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">alert&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">GpuThermalOrPowerThrottle&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">expr&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">(DCGM_FI_DEV_CLOCK_THROTTLE_REASONS != 0) and ignoring(reason) (DCGM_FI_DEV_CLOCK_THROTTLE_REASONS != 1)&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">for&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">1m&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">labels&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>{&lt;span class="w"> &lt;/span>&lt;span class="nt">severity&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">warning }&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">annotations&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">summary&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;GPU {{ $labels.gpu }} throttling (reasons={{ $value }})&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>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">alert&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">GpuXidErrorDetected&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">expr&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">increase(DCGM_FI_DEV_XID_ERRORS[5m]) &amp;gt; 0&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">labels&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>{&lt;span class="w"> &lt;/span>&lt;span class="nt">severity&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">critical }&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">annotations&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">summary&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;XID error on GPU {{ $labels.gpu }} — investigate immediately&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>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">alert&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">GpuEccDoubleBit&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">expr&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">DCGM_FI_DEV_ECC_DBE_VOL_TOTAL &amp;gt; 0&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">labels&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>{&lt;span class="w"> &lt;/span>&lt;span class="nt">severity&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">critical }&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">annotations&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">summary&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;ECC double-bit on GPU {{ $labels.gpu }} — drain node&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>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">alert&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">VllmKvCachePoolNearFull&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">expr&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">vllm:gpu_cache_usage_perc &amp;gt; 0.95&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">for&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">3m&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">labels&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>{&lt;span class="w"> &lt;/span>&lt;span class="nt">severity&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">warning }&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">annotations&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">summary&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;KV cache pool &amp;gt; 95% on {{ $labels.instance }}&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>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">alert&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">VllmTtftP95OutOfSlo&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">expr&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">histogram_quantile(0.95, sum by(le, instance)(rate(vllm:time_to_first_token_seconds_bucket[5m]))) &amp;gt; 1.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 class="nt">for&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">5m&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">labels&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>{&lt;span class="w"> &lt;/span>&lt;span class="nt">severity&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">warning }&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">annotations&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">summary&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;TTFT P95 over SLO ({{ $value }}s &amp;gt; 1.5s)&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>These six cover 80 % of the incidents that affect the SLO. The remaining 20 % demand investigation with tracing (see &lt;a href="https://blog.lo0.es/en/posts/llm-tracing-opentelemetry-genai/">LLM tracing with OpenTelemetry GenAI&lt;/a>).&lt;/p>
&lt;h2 id="master-table-thresholds-and-queries">Master table: thresholds and queries&lt;/h2>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Metric&lt;/th>
&lt;th>Green&lt;/th>
&lt;th>Amber&lt;/th>
&lt;th>Red&lt;/th>
&lt;th>Base query (PromQL)&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>SM occupancy&lt;/td>
&lt;td>0.30–0.70&lt;/td>
&lt;td>0.15–0.30&lt;/td>
&lt;td>&amp;lt; 0.10 sustained&lt;/td>
&lt;td>&lt;code>DCGM_FI_PROF_SM_OCCUPANCY&lt;/code>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Tensor active (decode)&lt;/td>
&lt;td>15–30 %&lt;/td>
&lt;td>&amp;lt; 10 %&lt;/td>
&lt;td>&amp;lt; 3 %&lt;/td>
&lt;td>&lt;code>DCGM_FI_PROF_PIPE_TENSOR_ACTIVE&lt;/code>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>DRAM active&lt;/td>
&lt;td>60–85 %&lt;/td>
&lt;td>85–95 %&lt;/td>
&lt;td>&amp;gt; 95 % with low KV&lt;/td>
&lt;td>&lt;code>DCGM_FI_PROF_DRAM_ACTIVE&lt;/code>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>FB used&lt;/td>
&lt;td>70–85 %&lt;/td>
&lt;td>86–92 %&lt;/td>
&lt;td>&amp;gt; 92 %&lt;/td>
&lt;td>&lt;code>100 * DCGM_FI_DEV_FB_USED / DCGM_FI_DEV_FB_TOTAL&lt;/code>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>NVLink BW&lt;/td>
&lt;td>&amp;lt; 70 % cap&lt;/td>
&lt;td>70–90 % cap&lt;/td>
&lt;td>&amp;gt; 90 % cap&lt;/td>
&lt;td>&lt;code>DCGM_FI_DEV_NVLINK_BANDWIDTH_TOTAL&lt;/code>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>GPU temp&lt;/td>
&lt;td>&amp;lt; 75 °C&lt;/td>
&lt;td>75–82 °C&lt;/td>
&lt;td>&amp;gt; 83 °C&lt;/td>
&lt;td>&lt;code>DCGM_FI_DEV_GPU_TEMP&lt;/code>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Power usage&lt;/td>
&lt;td>&amp;lt; 90 % TDP&lt;/td>
&lt;td>90–98 % TDP&lt;/td>
&lt;td>&amp;gt; 98 % TDP&lt;/td>
&lt;td>&lt;code>DCGM_FI_DEV_POWER_USAGE&lt;/code>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Throttle reasons&lt;/td>
&lt;td>0 or Idle&lt;/td>
&lt;td>App/SW&lt;/td>
&lt;td>HW Therm/Power&lt;/td>
&lt;td>&lt;code>DCGM_FI_DEV_CLOCK_THROTTLE_REASONS&lt;/code>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>XID errors&lt;/td>
&lt;td>no change&lt;/td>
&lt;td>—&lt;/td>
&lt;td>any delta&lt;/td>
&lt;td>&lt;code>increase(DCGM_FI_DEV_XID_ERRORS[5m])&lt;/code>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>ECC DBE&lt;/td>
&lt;td>0&lt;/td>
&lt;td>—&lt;/td>
&lt;td>&amp;gt; 0&lt;/td>
&lt;td>&lt;code>DCGM_FI_DEV_ECC_DBE_VOL_TOTAL&lt;/code>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Retired pages&lt;/td>
&lt;td>&amp;lt; 4&lt;/td>
&lt;td>4–8&lt;/td>
&lt;td>&amp;gt; 8&lt;/td>
&lt;td>&lt;code>DCGM_FI_DEV_RETIRED_DBE&lt;/code>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>KV cache used&lt;/td>
&lt;td>50–80 %&lt;/td>
&lt;td>80–92 %&lt;/td>
&lt;td>&amp;gt; 92 %&lt;/td>
&lt;td>&lt;code>vllm:gpu_cache_usage_perc&lt;/code>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Requests waiting&lt;/td>
&lt;td>0&lt;/td>
&lt;td>1–5 sustained&lt;/td>
&lt;td>&amp;gt; 10 sustained&lt;/td>
&lt;td>&lt;code>vllm:num_requests_waiting&lt;/code>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>TTFT P95&lt;/td>
&lt;td>&amp;lt; SLO&lt;/td>
&lt;td>80–100 % SLO&lt;/td>
&lt;td>&amp;gt; SLO&lt;/td>
&lt;td>see alert query above&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>TPOT P95&lt;/td>
&lt;td>&amp;lt; SLO&lt;/td>
&lt;td>80–100 % SLO&lt;/td>
&lt;td>&amp;gt; SLO&lt;/td>
&lt;td>&lt;code>histogram_quantile(0.95, sum by(le)(rate(vllm:time_per_output_token_seconds_bucket[5m])))&lt;/code>&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;h2 id="three-pitfalls-that-confuse-the-junior-operator">Three pitfalls that confuse the junior operator&lt;/h2>
&lt;p>&lt;strong>Pitfall 1 — &amp;ldquo;GPU-Util at 99 % = saturated&amp;rdquo;.&lt;/strong> As explained at the start, &lt;code>DCGM_FI_DEV_GPU_UTIL&lt;/code> lights up with any kernel. The right move is to look at the three &lt;code>_PROF_*&lt;/code> metrics (SM occupancy, tensor active, DRAM active) together. &lt;em>GPU util 99 % + tensor active 8 % + DRAM active 92 %&lt;/em> means &amp;ldquo;saturated by memory, not compute&amp;rdquo;; &lt;em>GPU util 99 % + tensor active 75 % + DRAM active 50 %&lt;/em> means &amp;ldquo;saturated by compute, prefill heavy&amp;rdquo;. The two situations call for different levers.&lt;/p>
&lt;p>&lt;strong>Pitfall 2 — confusing single-bit ECC (SBE) with double-bit (DBE).&lt;/strong> Single-bit errors are corrected silently and are &lt;strong>unavoidable&lt;/strong> in any HBM under load (cosmic radiation, voltage fluctuations). An SBE counter growing slowly is not an alert, it is physics. DBE is: it corrupts data. Telling the two metrics apart avoids false alarms and false negatives in equal measure.&lt;/p>
&lt;p>&lt;strong>Pitfall 3 — alerting on &lt;code>num_requests_waiting &amp;gt; 0&lt;/code> without context.&lt;/strong> An instantaneous value of 1 or 2 during a spike is normal. What matters is a &lt;strong>sustained&lt;/strong> queue: use &lt;code>for: 5m&lt;/code> with a threshold of 3–5. Without that window, the system floods the alert channel with noise.&lt;/p>
&lt;h2 id="applied-to-typical-on-premise-hardware">Applied to typical on-premise hardware&lt;/h2>
&lt;p>For a generic cluster of &lt;strong>4×H100 SXM 80 GB with intra-node NVLink&lt;/strong>:&lt;/p>
&lt;ul>
&lt;li>DCGM Exporter deployed through the NVIDIA GPU Operator, one DaemonSet per GPU node.&lt;/li>
&lt;li>Internal Prometheus with 30-day retention for high-frequency metrics, 1 year for downsampled ones (Thanos/Mimir if the volume justifies it).&lt;/li>
&lt;li>Grafana with three standard dashboards: GPU hardware (DCGM), engine (vLLM), SLO (TTFT/TPOT/RPS against written targets).&lt;/li>
&lt;li>Alertmanager with an on-call rotation and rate limiting through silences grouped by node.&lt;/li>
&lt;li>Cardinality kept under control: &lt;code>gpu&lt;/code> (local id), &lt;code>node&lt;/code>, &lt;code>pod&lt;/code>, &lt;code>model&lt;/code>. Do not add &lt;code>request_id&lt;/code> or other high-cardinality labels to metrics; that is tracing&amp;rsquo;s job.&lt;/li>
&lt;/ul>
&lt;p>Estimated volume for a 16-GPU cluster scraped every 15 s: around 2 million samples/min, around 25 GB/day of raw Prometheus. Manageable with one Prometheus per cluster plus retention; if the team scales beyond 64 GPUs, consider a Thanos sidecar or VictoriaMetrics. See &lt;a href="https://blog.lo0.es/en/posts/oss-catalogue-llmops-six-stages-tool-by-tool/">OSS LLMOps tooling catalogue&lt;/a> for equivalent alternatives.&lt;/p>
&lt;h2 id="what-we-have-not-covered-upcoming-articles">What we have not covered (upcoming articles)&lt;/h2>
&lt;ul>
&lt;li>&lt;strong>Tracing of LLM workloads&lt;/strong>: already covered in &lt;a href="https://blog.lo0.es/en/posts/llm-tracing-opentelemetry-genai/">LLM tracing with OpenTelemetry GenAI&lt;/a>.&lt;/li>
&lt;li>&lt;strong>Autoscaling&lt;/strong> based on these metrics: see &lt;a href="https://blog.lo0.es/en/posts/llm-inference-autoscaling-kubernetes-hpa-keda/">Autoscaling LLM on Kubernetes&lt;/a>.&lt;/li>
&lt;li>&lt;strong>Incident response runbooks&lt;/strong>: how each of these alerts translates into concrete action (drain, restart, RMA, scaling, rollback).&lt;/li>
&lt;li>&lt;strong>Cost accounting&lt;/strong>: using &lt;code>DCGM_FI_DEV_POWER_USAGE&lt;/code> and &lt;code>vllm:request_success_total&lt;/code> for per-tenant cost showback.&lt;/li>
&lt;li>&lt;strong>Multi-tenant fairness monitoring&lt;/strong>: when several tenants share a cluster, which metrics detect that one of them is hogging the KV cache.&lt;/li>
&lt;/ul>
&lt;h2 id="see-also">See also&lt;/h2>
&lt;ul>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/finops-multi-tenancy-gpu-cluster-who-pays/">FinOps and multi-tenancy on the GPU cluster: who pays for what&lt;/a> — DCGM utilisation as the cluster&amp;rsquo;s FinOps governance metric.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/llm-tracing-opentelemetry-genai/">LLM tracing with OpenTelemetry GenAI&lt;/a> — the other half of observability.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/capacity-planning-on-premise-llm-inference/">Capacity planning for on-premise LLM inference&lt;/a> — what was sized and, therefore, which thresholds are defensible here.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/continuous-batching-orca-vllm-chunked-prefill-goodput/">Continuous batching&lt;/a> — explains why &lt;code>num_requests_running&lt;/code>, &lt;code>num_requests_waiting&lt;/code> and &lt;code>gpu_cache_usage_perc&lt;/code> are the engine&amp;rsquo;s operational metrics.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/five-maturity-levels-platform-under-llm/">Five maturity levels&lt;/a> — LLM-aware observability lives at level 4.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/anatomy-on-premise-llm-inference-stack-seven-layers/">Seven layers of the on-premise LLM inference stack&lt;/a> — DCGM Exporter is a piece of the platform layer.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/llm-inference-autoscaling-kubernetes-hpa-keda/">Autoscaling LLM on Kubernetes&lt;/a> — uses these metrics as input.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/anatomy-dcgm-vllm-metrics-analogies-documented-anomalies/">Anatomy of the twelve DCGM and five vLLM metrics&lt;/a> — a deeper dive with an analogy and a documented production anomaly for each metric, with figures from public incidents (Meta Llama 3, &lt;em>Story of Two GPUs&lt;/em>, vLLM issues, Dell/Lenovo KBs).&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/incident-response-runbooks-llm-inference-kafka-keep/">Incident response runbooks for LLM with Keep + Kafka&lt;/a> — the translation of each critical alert into concrete action (drain, reset, RMA, rollback) with workflow YAML, a WORM Kafka schema and the fit with ISO 27035, ENS, NIS2 and EU AI Act art. 73.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/nvlink-nvswitch-nccl-cable-every-token-crosses/">NVLink, NVSwitch and NCCL: the cable every token travels through&lt;/a> — the NVLink counters (&lt;code>nvidia-smi nvlink -e&lt;/code>, TX/RX bytes per link, CRC errors) that these DCGM metrics expose: a slow all-reduce shows up in a cable error counter before it shows up in API latency.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/gpu-idle-cost-not-on-any-invoice/">GPU utilisation as a FinOps lever&lt;/a> — from the DCGM metrics (GPU_UTIL vs SM_ACTIVE, MFU) to the cost of idle and the cost per token.&lt;/p>
&lt;/li>
&lt;/ul>
&lt;h2 id="references">References&lt;/h2>
&lt;ul>
&lt;li>NVIDIA — &lt;em>DCGM Exporter&lt;/em> (repo &lt;code>nvidia/dcgm-exporter&lt;/code>, documented metrics and units).&lt;/li>
&lt;li>NVIDIA — &lt;em>DCGM Field Identifiers reference&lt;/em> (complete list of &lt;code>DCGM_FI_*&lt;/code>).&lt;/li>
&lt;li>NVIDIA — &lt;em>XID Errors documentation&lt;/em> (catalogue of XID codes and remediation procedures).&lt;/li>
&lt;li>NVIDIA — &lt;em>NVIDIA GPU Operator&lt;/em> (official Helm chart).&lt;/li>
&lt;li>vLLM project — &lt;code>examples/production_monitoring/&lt;/code> (reference PromQL and Grafana dashboards).&lt;/li>
&lt;li>Prometheus — &lt;em>Histogram and summary best practices&lt;/em> (for building defensible percentile queries).&lt;/li>
&lt;li>NVIDIA — &lt;em>H100 Tensor Core GPU datasheet&lt;/em> (TDP, HBM bandwidth, NVLink capacities).&lt;/li>
&lt;/ul></description></item><item><title>Anatomy of an on-premise LLM inference stack: the seven layers that have to hold each other up</title><link>https://blog.lo0.es/en/posts/anatomy-on-premise-llm-inference-stack-seven-layers/</link><pubDate>Sat, 30 May 2026 16:00:00 +0200</pubDate><guid>https://blog.lo0.es/en/posts/anatomy-on-premise-llm-inference-stack-seven-layers/</guid><description>&lt;h2 id="tldr">TL;DR&lt;/h2>
&lt;p>The &lt;strong>on-premise LLM inference stack is not a model server&lt;/strong>: it is a building of &lt;strong>seven layers&lt;/strong> that hold each other up. The inference layer (vLLM / SGLang) serves tokens, but without a dedicated embeddings layer RAG does not work; without the gateway layer the client couples its SDK to the specific engine; without an LLM-aware observability layer (Langfuse + OpenTelemetry GenAI) any quality degradation goes unnoticed; without a GitOps control plane layer (Flux + Forgejo) any manual change leaves invisible debt; and without a &lt;strong>dependency tracking&lt;/strong> layer (Hubble + intent-based policies) decommissioning a Service silently breaks applications nobody remembers were using it. This post is born from a concrete incident: a pipeline that reported &lt;code>status: completed&lt;/code> and &lt;code>matched_jobs: 0&lt;/code> for days because it kept calling an Ollama that had already been scaled to zero, while a badly written &lt;code>except&lt;/code> labelled the generic &lt;code>ConnectError&lt;/code> as &amp;ldquo;ChromaDB indexing error&amp;rdquo; and the vector store, innocent, took the blame. Each of the three symptoms was the cry of a layer that was missing or badly designed. The body of the post walks the seven layers with their canonical OSS piece, the design decisions that break them, the sizing maths on a generic &lt;strong>4×H100 SXM cluster (320 GB of VRAM, NVLink)&lt;/strong> and a final diagram of the connected stack. The thesis: a stack that passes the incident test is not measured by its peak throughput, it is measured by &lt;strong>how long it takes to shout when something drifts from the design&lt;/strong>.&lt;/p>
&lt;h2 id="you-are-here-the-seven-layers-seen-from-above">You are here: the seven layers seen from above&lt;/h2>
&lt;p>Before going into detail, the map. The seven layers are not seven servers: they are seven responsibilities the stack has to cover. A layer can collapse into one pod (gateway) or spread across several components (observability = traces + metrics + flow logs). What it cannot do is &lt;strong>be missing&lt;/strong>.&lt;/p>
&lt;div class="diagram" style="max-width:820px;margin:1rem auto;">
&lt;svg viewBox="0 0 820 360" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="the seven layers of the on-premise LLM inference stack">&lt;style>
.layer{stroke:#333;stroke-width:1.4;rx:6}
.l1{fill:#ffd9b8;stroke:#a44}
.l2{fill:#ffe6c2;stroke:#a55}
.l3{fill:#fff0d0;stroke:#a66}
.l4{fill:#dfe9f5;stroke:#356}
.l5{fill:#d8eecf;stroke:#373}
.l6{fill:#f5e3d8;stroke:#763}
.l7{fill:#ead8f5;stroke:#634}
.title{font:600 13px sans-serif;fill:#222}
.sm{font:11px sans-serif;fill:#222}
.tiny{font:600 10px sans-serif;fill:#222}
.note{font:italic 10px sans-serif;fill:#555}
.arr{stroke:#666;stroke-width:1.4;fill:none;marker-end:url(#a)}
&lt;/style>
&lt;defs>&lt;marker id="a" 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="410" y="22" text-anchor="middle" class="title">On-premise LLM inference stack — seven layers, one responsibility each&lt;/text>
&lt;rect x="40" y="40" width="740" height="38" class="layer l1"/>
&lt;text x="60" y="64" class="sm">&lt;tspan font-weight="700">1 · Gateway&lt;/tspan> — Envoy AI Gateway · Cilium Gateway API · LiteLLM &lt;tspan class="note">OpenAI-compatible · JWT auth · routing by model · rate limit&lt;/tspan>&lt;/text>
&lt;rect x="40" y="86" width="365" height="50" class="layer l2"/>
&lt;text x="60" y="106" class="sm">&lt;tspan font-weight="700">2 · LLM inference&lt;/tspan>&lt;/text>
&lt;text x="60" y="122" class="tiny">vLLM · SGLang · TensorRT-LLM &lt;tspan class="note" fill="#555">multi-model · multi-LoRA · FP8 KV&lt;/tspan>&lt;/text>
&lt;rect x="415" y="86" width="365" height="50" class="layer l3"/>
&lt;text x="435" y="106" class="sm">&lt;tspan font-weight="700">3 · Embeddings&lt;/tspan>&lt;/text>
&lt;text x="435" y="122" class="tiny">Infinity · TEI · OVMS · sentence-transformers &lt;tspan class="note" fill="#555">fixed dim · separate from the LLM&lt;/tspan>&lt;/text>
&lt;rect x="40" y="144" width="740" height="50" class="layer l4"/>
&lt;text x="60" y="164" class="sm">&lt;tspan font-weight="700">4 · Vector store + relational data&lt;/tspan>&lt;/text>
&lt;text x="60" y="180" class="tiny">Qdrant · pgvector · MinIO (weights · adapters · corpus) &lt;tspan class="note" fill="#555">collection versioned by dim · bucket per tenant&lt;/tspan>&lt;/text>
&lt;rect x="40" y="202" width="740" height="50" class="layer l5"/>
&lt;text x="60" y="222" class="sm">&lt;tspan font-weight="700">5 · LLM-aware observability + infrastructure + network&lt;/tspan>&lt;/text>
&lt;text x="60" y="238" class="tiny">Langfuse · OTel GenAI · VictoriaMetrics · Grafana · Hubble · DCGM &lt;tspan class="note" fill="#555">end-to-end trace_id&lt;/tspan>&lt;/text>
&lt;rect x="40" y="260" width="740" height="40" class="layer l6"/>
&lt;text x="60" y="284" class="sm">&lt;tspan font-weight="700">6 · GitOps control plane&lt;/tspan> — Flux · Forgejo · cert-manager · External Secrets &lt;tspan class="note" fill="#555">binding drift detection&lt;/tspan>&lt;/text>
&lt;rect x="40" y="308" width="740" height="40" class="layer l7"/>
&lt;text x="60" y="332" class="sm">&lt;tspan font-weight="700">7 · Dependency tracking&lt;/tspan> — Hubble flow logs · Otterize · NetworkPolicy as code &lt;tspan class="note" fill="#555">who calls what, observed and declared&lt;/tspan>&lt;/text>
&lt;/svg>
&lt;/div>
&lt;p>Layers 1–4 sit on the &lt;strong>request path&lt;/strong>: if they fall, the client notices in seconds. Layers 5–7 sit on the path of &lt;strong>design and operations&lt;/strong>: if they fall, there is no immediately visible error, and that is why they are the ones that produce silent incidents, which is how most serious incidents end up. This post argues that &lt;strong>the quality of the stack is measured in layers 5, 6 and 7&lt;/strong>, because 1–4 are commodities where everyone picks roughly the same pieces.&lt;/p>
&lt;h2 id="the-analogy-the-office-building-with-shared-services">The analogy: the office building with shared services&lt;/h2>
&lt;p>Picture a twelve-storey office building with several tenants. It has a &lt;strong>concierge desk&lt;/strong> (gateway: filters who comes in), it has &lt;strong>lifts&lt;/strong> (LLM inference: they move the heavy load), it has &lt;strong>stairs and a goods lift&lt;/strong> (embeddings: light but constant traffic, hardly anyone notices them until they break), it has &lt;strong>plumbing and water tanks&lt;/strong> (vector store: what actually holds the state), it has a &lt;strong>distribution board and sensors&lt;/strong> (observability: what warns you when something is drawing more than planned), it has a &lt;strong>building manager&lt;/strong> (GitOps control plane: the only legitimate authority to move anything), and it has a &lt;strong>tenant register&lt;/strong> (dependency tracking: who is connected to which shared service).&lt;/p>
&lt;p>When a new client moves into the third floor and asks to install a server that needs more amperage than planned, the problem is not electrical: it is &lt;strong>administrative&lt;/strong>. If the client can go straight to the board and plug in whatever they like, the building survives for a while and then a breaker trips at three in the morning. If the client has to go through the manager, the manager consults the tenant register (is anyone else hanging off that same circuit?), reviews the electrical planning (are we at the limit?) and either authorises or redirects. The building stays standing not because of its electrical installation but because of the &lt;strong>discipline of going through the manager&lt;/strong>.&lt;/p>
&lt;p>The LLM inference stack works identically. The physical layers (1–4) are the visible ones, the ones marketing people put on the slide. The governance layers (5–7) are the ones that distinguish a platform from a pile of pods with good luck.&lt;/p>
&lt;p>Now to the incident that motivates the whole post.&lt;/p>
&lt;h2 id="the-hook-the-log-that-lied-for-six-days">The hook: the log that lied for six days&lt;/h2>
&lt;p>The application is called &lt;em>jobhunter&lt;/em> internally. It is a cron pipeline that every six hours sweeps public job-ad sources, filters by EU geography, embeds the new ads, indexes them in a vector store and matches them against search profiles. The last step fires notifications.&lt;/p>
&lt;p>For six days the pipeline reported the same thing on every run:&lt;/p>
&lt;pre tabindex="0">&lt;code>status: completed
total_found: 756
new: 23
matched_jobs: 0 ← zero, run after run
&lt;/code>&lt;/pre>&lt;p>And in the logs:&lt;/p>
&lt;pre tabindex="0">&lt;code>[INFO] httpx: POST chromadb:8000/api/v2/.../collections/&amp;lt;id&amp;gt;/delete → 200 OK
[INFO] matcher: Purged 12 expired jobs from ChromaDB
[ERROR] pipeline: ChromaDB indexing error: All connection attempts failed
[INFO] httpx: POST chromadb:8000/api/v2/.../collections/&amp;lt;id&amp;gt;/get → 200 OK
[ERROR] pipeline: Matching error: All connection attempts failed
&lt;/code>&lt;/pre>&lt;p>This is a log that invites you to blame ChromaDB. And in fact the first post-mortem written internally pointed at a version incompatibility between the Python client and the v2 server. A reasonable hypothesis, technically plausible, completely false.&lt;/p>
&lt;p>The real cause: weeks earlier the general LLM on the platform had been migrated from &lt;strong>Ollama&lt;/strong> to &lt;strong>vLLM&lt;/strong>. The migration was clean for the two applications that depended directly on the large model, their manifests were pointed at the new endpoint. What nobody did was look at who else was calling the Service &lt;code>ollama.ollama.svc:11434&lt;/code>. &lt;em>jobhunter&lt;/em> called it to generate the embeddings for the ads. When the Ollama deployment was scaled to zero, the Service was left empty, and any outbound connection received a generic &lt;code>ConnectError(&amp;quot;All connection attempts failed&amp;quot;)&lt;/code> from &lt;code>httpcore&lt;/code>. The &lt;code>try/except&lt;/code> wrapping the whole matching stage caught the exception and labelled it &lt;em>&amp;ldquo;ChromaDB indexing error&amp;rdquo;&lt;/em>, because that was the lexical wrapper of the block, not because ChromaDB had anything to do with it. ChromaDB was answering 200 to &lt;em>delete&lt;/em> and &lt;em>get&lt;/em> in those same logs.&lt;/p>
&lt;p>Three factors kept the incident alive for six days:&lt;/p>
&lt;ol>
&lt;li>The &lt;strong>&lt;code>except&lt;/code> named the wrapper rather than the stage that failed&lt;/strong>. The log said &lt;em>&amp;ldquo;ChromaDB indexing error&amp;rdquo;&lt;/em> when the error was an embeddings call against a nonexistent Service.&lt;/li>
&lt;li>The pipeline returned &lt;strong>&lt;code>status: completed&lt;/code>&lt;/strong> even when there were errors. No alerting based on &lt;code>status&lt;/code> fired. The metric that would have fired (matched_jobs stuck at zero) was not instrumented.&lt;/li>
&lt;li>The pipeline image ran as &lt;strong>&lt;code>:latest&lt;/code> with no versioning&lt;/strong>, no SBOM, no reproducibility. When it started failing, it was impossible to know which Ollama client it had been built with.&lt;/li>
&lt;/ol>
&lt;p>Each of the three symptoms is the cry of a layer that was missing. The first asks for &lt;strong>LLM-aware observability&lt;/strong> that distinguishes stages (layer 5). The second asks for the &lt;strong>pipeline logic&lt;/strong> and the Prometheus metrics to behave like contracts (layer 5, SLI/SLO dimension). The third asks for &lt;strong>GitOps with pinned images&lt;/strong> and an auditable SBOM (layer 6). And the whole incident, decommissioning a Service without knowing who consumes it, shouts &lt;strong>dependency tracking&lt;/strong> (layer 7).&lt;/p>
&lt;p>The rest of the post walks the seven layers through that lens: what each one solves, which OSS piece implements it in 2026, and which typical design decision breaks it.&lt;/p>
&lt;h2 id="layer-1--gateway-the-client-sdk-must-not-couple-to-the-engine">Layer 1 — Gateway: the client SDK must not couple to the engine&lt;/h2>
&lt;p>&lt;strong>What it has to solve.&lt;/strong> That the client sends &lt;code>POST /v1/chat/completions&lt;/code> with the standard OpenAI SDK and never learns which engine (vLLM, SGLang, TensorRT-LLM), which specific model, which LoRA adapter or even which GPU pool is serving the request. Authentication, rate limit, routing by &lt;code>body.model&lt;/code> and by tenant, header injection for tracing.&lt;/p>
&lt;p>&lt;strong>Canonical OSS piece in 2026.&lt;/strong> &lt;strong>Envoy AI Gateway&lt;/strong> (Envoy with GenAI extensions: routing by model, token-based rate limit, fallback chains) or &lt;strong>Cilium Gateway API&lt;/strong> with your own filters. For cases where the client wants multi-provider without distinguishing on-prem from SaaS, &lt;strong>LiteLLM Proxy&lt;/strong> is the lightweight equivalent.&lt;/p>
&lt;p>&lt;strong>What breaks the layer.&lt;/strong> Exposing the inference engine endpoint directly. If clients call &lt;code>http://vllm-prod.svc:8000&lt;/code>, any change of engine, model or pool forces you to touch the code of every app. The rule: &lt;strong>the engine changes, the contract does not&lt;/strong>. The OpenAI SDK is standard; the routing behind the gateway is where design freedom lives.&lt;/p>
&lt;p>&lt;strong>Where &lt;em>jobhunter&lt;/em> failed.&lt;/strong> There was no gateway. The app called &lt;code>ollama.ollama.svc:11434&lt;/code> directly. When Ollama died, there was no intermediate layer that could answer with a &lt;em>fallback&lt;/em>, a &lt;em>retry against another pool&lt;/em>, or at least a &lt;em>503 error with a descriptive body&lt;/em>.&lt;/p>
&lt;h2 id="layer-2--llm-inference-vllm-as-the-default-choice-sglang-when-prefix-caching-rules">Layer 2 — LLM inference: vLLM as the default choice, SGLang when prefix caching rules&lt;/h2>
&lt;p>&lt;strong>What it has to solve.&lt;/strong> Serving tokens with throughput and latency under control: continuous batching, PagedAttention so the KV cache does not fragment the VRAM, FP8 so a 32B model fits with room to spare, multi-LoRA for per-tenant customisation without replicating the base, structured output for function calling with schema guarantees.&lt;/p>
&lt;p>&lt;strong>Canonical OSS piece in 2026.&lt;/strong> &lt;strong>vLLM&lt;/strong> is the default choice: it covers the state of the art (&lt;a href="https://blog.lo0.es/en/posts/kv-cache-working-memory-llm-inference/">PagedAttention&lt;/a>, &lt;a href="https://blog.lo0.es/en/posts/continuous-batching-orca-vllm-chunked-prefill-goodput/">continuous batching&lt;/a>, &lt;a href="https://blog.lo0.es/en/posts/flashattention-librarian-io-awareness-async-blackwell-asymmetry/">FlashAttention&lt;/a>, &lt;a href="https://blog.lo0.es/en/posts/quantization-llm-inference-fp8-int4-gguf/">FP8 quantisation&lt;/a>, &lt;a href="https://blog.lo0.es/en/posts/multi-lora-serving-shared-base-concurrent-adapters-sgmv-kernel/">multi-LoRA&lt;/a>, &lt;a href="https://blog.lo0.es/en/posts/structured-output-bitmask-outlines-xgrammar-llguidance/">structured output&lt;/a>, &lt;a href="https://blog.lo0.es/en/posts/speculative-decoding-fundamentals-maths-state-of-play/">speculative decoding&lt;/a>). &lt;strong>SGLang&lt;/strong> comes in when the workload has high prefix caching (long chat with large system prompts, agents with repeated instructions), because its RadixAttention composes better than standard prefix caching. For very specialised inference with proprietary NVIDIA kernels, &lt;strong>TensorRT-LLM&lt;/strong>, accepting the hardware lock-in.&lt;/p>
&lt;p>&lt;strong>What breaks the layer.&lt;/strong> Assuming a single model &amp;ldquo;does everything&amp;rdquo;. A 32B chat LLM does not serve &lt;code>/v1/embeddings&lt;/code>; if you try, vLLM answers &lt;code>BadRequestError: &amp;quot;The model does not support Embeddings API&amp;quot;&lt;/code>. Assuming it did was one of the concrete wounds of the incident: the app expected an endpoint the model did not implement.&lt;/p>
&lt;p>&lt;strong>The design decision that costs more later.&lt;/strong> Serving the LLM with aggressive quantisation (INT4) without a quality eval calibrated for your corpus. INT4 with AWQ or GPTQ saves VRAM, but it degrades answers in technical or legal Spanish in a measurable way. The rule: any quantisation change goes through the same golden eval as a model change.&lt;/p>
&lt;h2 id="layer-3--embeddings-separate-from-the-llm-fixed-dimension-a-life-of-its-own">Layer 3 — Embeddings: separate from the LLM, fixed dimension, a life of its own&lt;/h2>
&lt;p>&lt;strong>What it has to solve.&lt;/strong> Generating dense vectors for the RAG corpus and for retrieval queries. The embeddings model is &lt;strong>a different thing&lt;/strong> from the chat LLM: different architecture (encoder, not decoder), different size (hundreds of millions of parameters, not tens of thousands of millions), different API (an &lt;code>/embeddings&lt;/code> endpoint that takes text and returns a fixed-dimension vector).&lt;/p>
&lt;p>&lt;strong>Canonical OSS piece in 2026.&lt;/strong> &lt;strong>Infinity&lt;/strong> or &lt;strong>Hugging Face Text Embeddings Inference (TEI)&lt;/strong> to serve models of the &lt;code>bge-*&lt;/code>, &lt;code>multilingual-e5-*&lt;/code>, &lt;code>nomic-embed-*&lt;/code> families with high throughput and multi-model support. &lt;strong>OpenVINO Model Server&lt;/strong> when Intel hardware is available. &lt;strong>sentence-transformers&lt;/strong> as a fallback embedded in the application itself when the corpus is small and the deployment is constrained.&lt;/p>
&lt;p>&lt;strong>What breaks the layer.&lt;/strong> Treating it as &amp;ldquo;the LLM does that too&amp;rdquo;. It does not if it is chat-only; and even when it does (some models have a dual endpoint), mixing chat and embeddings serving in the same process punishes the throughput of both. The physical separation &lt;strong>is&lt;/strong> the design.&lt;/p>
&lt;p>&lt;strong>The technical fact people forget.&lt;/strong> The &lt;strong>vector store and the embeddings model form an indivisible unit&lt;/strong>. Changing the model from &lt;code>multilingual-e5-large&lt;/code> (1024 dim) to &lt;code>multilingual-e5-small&lt;/code> (384 dim) is &lt;strong>not a substitution&lt;/strong>: it is creating a new collection (&lt;code>my_corpus_v2&lt;/code>) and re-embedding the whole corpus. If you &lt;code>upsert&lt;/code> into the old collection, you hit a dim mismatch at runtime that takes the pod down. This seems obvious and is violated constantly, because the embeddings model is chosen once and then forgotten.&lt;/p>
&lt;h2 id="layer-4--vector-store--relational-data--storage-what-actually-holds-the-state">Layer 4 — Vector store + relational data + storage: what actually holds the state&lt;/h2>
&lt;p>&lt;strong>What it has to solve.&lt;/strong> Persisting the vectors with efficient filters (&lt;code>tenant_id&lt;/code>, &lt;code>created_at&lt;/code>, &lt;code>source&lt;/code>), persisting the relational metadata (users, configs, versioned prompts, traces), and persisting the model weights, the LoRA adapters, the datasets and the original corpora.&lt;/p>
&lt;p>&lt;strong>Canonical OSS pieces in 2026.&lt;/strong>&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Qdrant&lt;/strong> for large collections (&amp;gt;200 k vectors), payload-aware filters, multi-tenancy via collection or via field.&lt;/li>
&lt;li>&lt;strong>pgvector&lt;/strong> for small collections with mandatory relational joins (wanting to do &lt;code>WHERE doc.author = ... AND vector &amp;lt;=&amp;gt; $1&lt;/code> in the same SQL).&lt;/li>
&lt;li>&lt;strong>PostgreSQL operated by CloudNativePG (CNPG)&lt;/strong> for the relational side: Barman Cloud backups, replication, connection via pooler.&lt;/li>
&lt;li>&lt;strong>MinIO&lt;/strong> for S3-compatible objects: bucket per tenant, cross-site replication, weights and adapters versioned by sha256.&lt;/li>
&lt;li>&lt;strong>Redis&lt;/strong> for queues, rate-limit counters and a cache of frequent retrievals.&lt;/li>
&lt;/ul>
&lt;p>&lt;strong>What breaks the layer.&lt;/strong> Assuming the vector store is &lt;em>stateless ephemeral&lt;/em>. It is exactly the opposite: it is the component where losing state costs the most. Without verified backups of the vector store, an index corruption forces you to re-embed the whole corpus, and on a corpus of millions of documents that is hours or days of GPU.&lt;/p>
&lt;p>&lt;strong>The design decision that pays later.&lt;/strong> Forgetting to version the collection by &lt;strong>dimension&lt;/strong> and &lt;strong>embeddings model&lt;/strong>. Suggested convention: &lt;code>my_corpus__embed-multie5l__1024d__v3&lt;/code>. The name carries metadata; any change in any of the three attributes forces a new collection. It is ugly but it protects against an accidental &lt;code>upsert&lt;/code> with the wrong dim.&lt;/p>
&lt;h2 id="layer-5--observability-llm-aware-traces--infrastructure-metrics--flow-logs">Layer 5 — Observability: LLM-aware traces + infrastructure metrics + flow logs&lt;/h2>
&lt;p>&lt;strong>What it has to solve.&lt;/strong> That any inference can be recovered from its &lt;code>trace_id&lt;/code>, with all the &lt;code>gen_ai.*&lt;/code> attributes (&lt;a href="https://blog.lo0.es/en/posts/llm-tracing-opentelemetry-genai/">semantic conventions&lt;/a>) plus your own attributes (&lt;code>tenant_id&lt;/code>, &lt;code>adapter_id&lt;/code>, &lt;code>priority_tier&lt;/code>), a breakdown of latency (queue → prefill → decode → network), tokens consumed, tools invoked, exact model and adapter. And in parallel: Prometheus metrics from vLLM (&lt;code>vllm:num_requests_running&lt;/code>, &lt;code>vllm:gpu_cache_usage_perc&lt;/code>, &lt;code>vllm:prefix_cache_hit_rate&lt;/code>), from the GPU (DCGM Exporter), from the network (Hubble flow logs with drops and NetworkPolicy enforcement).&lt;/p>
&lt;p>&lt;strong>Canonical OSS pieces in 2026.&lt;/strong>&lt;/p>
&lt;ul>
&lt;li>&lt;strong>OpenTelemetry Collector&lt;/strong> as the single transport for traces, metrics and logs, with OTLP receivers and separate exporters per destination.&lt;/li>
&lt;li>&lt;strong>Langfuse&lt;/strong> self-hosted for the LLM-aware side: tracing, prompt versioning, evals with LLM-as-judge (&lt;a href="https://blog.lo0.es/en/posts/evals-llm-layer-after-tracing/">post&lt;/a> and &lt;a href="https://blog.lo0.es/en/posts/llm-as-judge-exam-marker-not-oracle/">post&lt;/a>).&lt;/li>
&lt;li>&lt;strong>VictoriaMetrics + Grafana&lt;/strong> for high-throughput TSDB metrics with long retention.&lt;/li>
&lt;li>&lt;strong>Hubble&lt;/strong> (Cilium) for L3/L4/L7 flow logs and NetworkPolicy visualisation.&lt;/li>
&lt;li>&lt;strong>DCGM Exporter&lt;/strong> for GPU metrics.&lt;/li>
&lt;/ul>
&lt;p>&lt;strong>What breaks the layer.&lt;/strong> Wrapping the whole pipeline stage in a single &lt;code>try/except&lt;/code> that labels the exception with the wrapper&amp;rsquo;s name. The operational rule: &lt;strong>one &lt;code>try/except&lt;/code> per stage&lt;/strong>, with the stage label in the message and a Prometheus metric with &lt;code>labels={&amp;quot;stage&amp;quot;: &amp;quot;&amp;lt;name&amp;gt;&amp;quot;}&lt;/code>. That way &lt;em>&amp;ldquo;ChromaDB indexing error&amp;rdquo;&lt;/em> would never have been the log for an embeddings failure; it would have been &lt;em>&amp;ldquo;Embeddings call failed: ConnectError(ollama:11434)&amp;rdquo;&lt;/em>.&lt;/p>
&lt;p>&lt;strong>Complementary rule.&lt;/strong> The pipeline returns &lt;code>status: completed&lt;/code> if and only if &lt;strong>there were no errors&lt;/strong>. With errors it returns &lt;code>completed_with_errors&lt;/code> or &lt;code>failed&lt;/code>, and the metric &lt;code>pipeline_errors_total{stage}&lt;/code> is incremented. An alert based on &lt;code>increase(pipeline_errors_total[1h]) &amp;gt; 0&lt;/code> fires before the second failed run. Without this discipline, observability exists but does not warn.&lt;/p>
&lt;h2 id="layer-6--gitops-control-plane-the-only-legitimate-authority">Layer 6 — GitOps control plane: the only legitimate authority&lt;/h2>
&lt;p>&lt;strong>What it has to solve.&lt;/strong> That the cluster state is the state declared in git. That any divergence between git and the cluster is visible and, in critical components, &lt;strong>auto-reconciled or auto-alerted&lt;/strong>. That every deployed image has an immutable tag (sha digest or semver pin), an SBOM (Trivy) and traceability back to the commit that introduced it.&lt;/p>
&lt;p>&lt;strong>Canonical OSS pieces in 2026.&lt;/strong>&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Flux&lt;/strong> (or ArgoCD) as the reconciler.&lt;/li>
&lt;li>&lt;strong>Forgejo&lt;/strong> (or Gitea, GitLab CE) as the self-hosted OSS forge.&lt;/li>
&lt;li>&lt;strong>cert-manager + Trust Manager&lt;/strong> for internal PKI.&lt;/li>
&lt;li>&lt;strong>External Secrets Operator + SOPS&lt;/strong> for encrypted versioned secrets.&lt;/li>
&lt;li>&lt;strong>Kyverno&lt;/strong> (or OPA Gatekeeper) for binding policies: deny &lt;code>:latest&lt;/code> images, deny pods without a NetworkPolicy, deny Services without an owner label.&lt;/li>
&lt;li>&lt;strong>Trivy&lt;/strong> for SBOM and vulnerability scanning of images in the CI pipeline.&lt;/li>
&lt;/ul>
&lt;p>&lt;strong>What breaks the layer.&lt;/strong> Images with a mutable tag (&lt;code>:latest&lt;/code>, &lt;code>:main&lt;/code>). Any &lt;code>kubectl edit&lt;/code> in production that is not reflected in git. &lt;code>main&lt;/code> branches with write permissions for humans without review. The rule: &lt;strong>if a human can mutate the cluster without going through a signed commit, you do not have GitOps, you have somebody&amp;rsquo;s whiteboard&lt;/strong>.&lt;/p>
&lt;p>&lt;strong>How it applies to the incident.&lt;/strong> If the pipeline image had been pinned to a sha digest (&lt;code>registry.interno.local/jobhunter@sha256:9af2...&lt;/code>), the team could have audited immediately which Ollama client it carried. With &lt;code>:latest&lt;/code>, not even that.&lt;/p>
&lt;h2 id="layer-7--dependency-tracking-the-layer-the-incident-exposed">Layer 7 — Dependency tracking: the layer the incident exposed&lt;/h2>
&lt;p>&lt;strong>What it has to solve.&lt;/strong> Knowing who calls which Service, both &lt;strong>declaratively&lt;/strong> (what the gitops repo says) and &lt;strong>observed&lt;/strong> (what has been seen crossing the network over the last N days). And, on mature platforms, propagating that information as &lt;strong>policy&lt;/strong>: if nobody declares and nobody observes traffic to the Service &lt;code>ollama.ollama.svc&lt;/code>, decommissioning it is safe; if somebody declares it or uses it, decommissioning it opens a ticket.&lt;/p>
&lt;p>&lt;strong>Canonical OSS pieces in 2026.&lt;/strong>&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Hubble&lt;/strong> (Cilium) for observed flow logs: &lt;code>hubble observe --to-namespace ollama --since 14d&lt;/code> gives the list of source namespaces that have talked to Ollama in the last two weeks.&lt;/li>
&lt;li>&lt;strong>Otterize&lt;/strong> for intent-based policy: each Deployment declares &lt;em>&amp;ldquo;I need to talk to &lt;code>ollama-svc&lt;/code>&amp;rdquo;&lt;/em>, and the operator generates the corresponding NetworkPolicy and maintains a browsable catalogue of who is trying to talk to what.&lt;/li>
&lt;li>&lt;strong>manual kubectl-grep&lt;/strong> as a fallback: &lt;code>kubectl get deployments,cronjobs,statefulsets -A -o yaml | grep -E 'ollama[.-]'&lt;/code> produces the declarative list.&lt;/li>
&lt;li>&lt;strong>NetworkPolicy as code&lt;/strong> reviewed in CI: every PR that touches a Service requires the associated policy to be kept or explicitly updated.&lt;/li>
&lt;/ul>
&lt;p>&lt;strong>A pre-decom checklist&lt;/strong> that the incident suggests codifying as a CI hook:&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">SVC&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;ollama.ollama.svc.cluster.local&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="c1"># (a) declarative grep in the gitops repo&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">git -C &lt;span class="nv">$GITOPS_REPO&lt;/span> grep -l &lt;span class="s2">&amp;#34;&lt;/span>&lt;span class="nv">$SVC&lt;/span>&lt;span class="s2">&amp;#34;&lt;/span> &lt;span class="o">||&lt;/span> &lt;span class="nb">echo&lt;/span> &lt;span class="s2">&amp;#34;OK declarative&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="c1"># (b) observed grep in Hubble (last 14 days)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">hubble observe --to-fqdn &lt;span class="s2">&amp;#34;&lt;/span>&lt;span class="nv">$SVC&lt;/span>&lt;span class="s2">&amp;#34;&lt;/span> --since 336h --output json &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> &lt;span class="p">|&lt;/span> jq -r &lt;span class="s1">&amp;#39;.source.namespace&amp;#39;&lt;/span> &lt;span class="p">|&lt;/span> sort -u &lt;span class="o">||&lt;/span> &lt;span class="nb">echo&lt;/span> &lt;span class="s2">&amp;#34;OK observed&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="c1"># (c) live grep in the cluster&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">kubectl get all -A -o yaml &lt;span class="p">|&lt;/span> grep -E &lt;span class="s2">&amp;#34;&lt;/span>&lt;span class="nv">$SVC&lt;/span>&lt;span class="s2">&amp;#34;&lt;/span> &lt;span class="o">||&lt;/span> &lt;span class="nb">echo&lt;/span> &lt;span class="s2">&amp;#34;OK live&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>If all three return empty, the decom is safe. If any of them has content, there is downstream debt still open. The &lt;em>jobhunter&lt;/em> incident is exactly what happens when this check does not exist: the team that decommissioned Ollama looked at the list of applications it knew depended on it directly; nobody looked at the list of those that depended on it silently.&lt;/p>
&lt;h2 id="the-maths-that-matter-sizing-the-stack-on-4h100-sxm-320-gb">The maths that matter: sizing the stack on 4×H100 SXM (320 GB)&lt;/h2>
&lt;p>The generic reference cluster for everything that follows: &lt;strong>4×H100 SXM 80 GB&lt;/strong>, &lt;strong>NVLink&lt;/strong> between the four, &lt;strong>640 GB&lt;/strong> of system RAM, &lt;strong>2×NVMe NVMe-oF&lt;/strong> for local storage of weights and caches, &lt;strong>redundant 25/100 GbE&lt;/strong> to the switch. Aggregate VRAM: &lt;strong>320 GB&lt;/strong>.&lt;/p>
&lt;p>The VRAM budget is not free. A first reasonable split for a stack that serves one large general LLM, a medium code-specialised model, embeddings and a reranker, with room for multi-LoRA and for the KV cache:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Component&lt;/th>
&lt;th>Reference model&lt;/th>
&lt;th>Quant&lt;/th>
&lt;th>Model weights&lt;/th>
&lt;th>Reserved KV cache&lt;/th>
&lt;th>Total VRAM&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>General LLM (TP=4)&lt;/td>
&lt;td>70B-instruct&lt;/td>
&lt;td>FP8 W8A8&lt;/td>
&lt;td>70 GB&lt;/td>
&lt;td>60 GB&lt;/td>
&lt;td>&lt;strong>130 GB&lt;/strong>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Code LLM (TP=2)&lt;/td>
&lt;td>32B-coder&lt;/td>
&lt;td>FP8 W8A8&lt;/td>
&lt;td>32 GB&lt;/td>
&lt;td>28 GB&lt;/td>
&lt;td>&lt;strong>60 GB&lt;/strong>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Embeddings&lt;/td>
&lt;td>multilingual-e5-large&lt;/td>
&lt;td>FP16&lt;/td>
&lt;td>1.3 GB&lt;/td>
&lt;td>n/a&lt;/td>
&lt;td>&lt;strong>8 GB&lt;/strong> (×2 replicas)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Reranker&lt;/td>
&lt;td>bge-reranker-v2-m3&lt;/td>
&lt;td>FP16&lt;/td>
&lt;td>0.6 GB&lt;/td>
&lt;td>n/a&lt;/td>
&lt;td>&lt;strong>4 GB&lt;/strong>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Multi-LoRA pool (over the general LLM)&lt;/td>
&lt;td>up to 16 adapters&lt;/td>
&lt;td>bf16&lt;/td>
&lt;td>16 × 0.4 GB ≈ 6 GB&lt;/td>
&lt;td>reuses the LLM&amp;rsquo;s KV&lt;/td>
&lt;td>&lt;strong>6 GB&lt;/strong>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Reserved for fragmentation + overhead&lt;/td>
&lt;td>&lt;/td>
&lt;td>&lt;/td>
&lt;td>&lt;/td>
&lt;td>&lt;/td>
&lt;td>&lt;strong>~30 GB&lt;/strong>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Total committed&lt;/strong>&lt;/td>
&lt;td>&lt;/td>
&lt;td>&lt;/td>
&lt;td>&lt;/td>
&lt;td>&lt;/td>
&lt;td>&lt;strong>~238 GB / 320 GB&lt;/strong>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Free headroom&lt;/strong>&lt;/td>
&lt;td>&lt;/td>
&lt;td>&lt;/td>
&lt;td>&lt;/td>
&lt;td>&lt;/td>
&lt;td>&lt;strong>~82 GB&lt;/strong>&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>The 26 % of free headroom is not waste: it is what lets the vLLM scheduler avoid preempting requests under moderate pressure, lets continuous batching group large batches without aborting, and lets a failover from the other site promote a standby without OOM.&lt;/p>
&lt;p>&lt;strong>Expected throughput&lt;/strong>, with the 70B general LLM in FP8 and tensor parallel 4, on an H100 SXM with continuous batching active and prefix caching at 35–55 % (typical in multi-turn chat with shared system prompts):&lt;/p>
$$
\text{aggregate tokens/second} \approx 1500 \text{ to } 2500
$$
&lt;p>for concurrency between 32 and 64 requests, with sub-second TTFT P95 on short prompts (&amp;lt;2k tokens) and TPOT P95 around &lt;strong>40–60 ms/token&lt;/strong> as perceived by the client. These numbers are &lt;strong>reasonable orders of magnitude&lt;/strong>, not guarantees: real throughput depends on the prompt mix, on whether speculative decoding (EAGLE-3) is active and burnt-in, and on the network cost between gateway and inference pods.&lt;/p>
&lt;p>&lt;strong>Embeddings throughput&lt;/strong> on two replicas of &lt;code>multilingual-e5-large&lt;/code> with dynamic batching:&lt;/p>
$$
\text{embeddings/second} \approx 3000 \text{ to } 6000 \quad (\text{optimal batch} \sim 64)
$$
&lt;p>Enough to reindex a corpus of &lt;strong>1 million documents&lt;/strong> in roughly an hour, assuming chunks of 512 tokens and two chunks per document on average. For corpora of tens of millions of documents, re-embedding is done by delta via CDC over the source (covered in &lt;a href="https://blog.lo0.es/en/posts/rag-corpus-curation-active-librarian/">RAG corpus curation&lt;/a>), not by full sweep.&lt;/p>
&lt;p>&lt;strong>Retrieval latency&lt;/strong> on Qdrant with HNSW (M=16, ef_construct=200) over a collection of 5 million 1024-dim vectors filtered by &lt;code>tenant_id&lt;/code>:&lt;/p>
$$
\text{P95 latency retrieve top-50} \approx 8 \text{ to } 25 \text{ ms}
$$
&lt;p>Below the cost of cross-encoder reranking (&lt;code>bge-reranker-v2-m3&lt;/code> over top-50 = roughly 30–60 ms more), and below any call to the LLM. The bottleneck in a well sized RAG pipeline is never the vector store: it is the LLM&amp;rsquo;s decoding.&lt;/p>
&lt;h2 id="final-diagram-the-complete-connected-stack">Final diagram: the complete connected stack&lt;/h2>
&lt;div class="diagram" style="max-width:820px;margin:1rem auto;">
&lt;svg viewBox="0 0 820 540" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="complete diagram of the on-premise LLM inference stack with its seven layers connected">&lt;style>
.b{stroke:#333;stroke-width:1.4;rx:6}
.gw{fill:#ffd9b8;stroke:#a44}
.llm{fill:#ffe6c2;stroke:#a55}
.emb{fill:#fff0d0;stroke:#a66}
.data{fill:#dfe9f5;stroke:#356}
.obs{fill:#d8eecf;stroke:#373}
.ctrl{fill:#f5e3d8;stroke:#763}
.dep{fill:#ead8f5;stroke:#634}
.bg{fill:#fafafa;stroke:#bbb;rx:8}
.lbl{font:600 12px sans-serif;fill:#222}
.sm{font:11px sans-serif;fill:#222}
.tiny{font:600 10px sans-serif;fill:#222}
.arr{stroke:#666;stroke-width:1.4;fill:none;marker-end:url(#a)}
.otel{stroke:#1a73e8;stroke-width:1.4;fill:none;stroke-dasharray:3 2;marker-end:url(#ab)}
.ctrlarr{stroke:#c66;stroke-width:1.2;fill:none;stroke-dasharray:5 3;marker-end:url(#ac)}
&lt;/style>
&lt;defs>
&lt;marker id="a" 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;marker id="ab" 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="#1a73e8"/>&lt;/marker>
&lt;marker id="ac" 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="#c66"/>&lt;/marker>
&lt;/defs>
&lt;text x="410" y="22" text-anchor="middle" class="lbl">On-premise LLM inference stack — seven layers, stable contracts between them&lt;/text>
&lt;!-- Client -->
&lt;rect x="340" y="36" width="140" height="32" class="b gw"/>
&lt;text x="410" y="56" text-anchor="middle" class="sm">Client · OpenAI SDK&lt;/text>
&lt;path class="arr" d="M410,68 L410,82"/>
&lt;!-- Gateway -->
&lt;rect x="80" y="86" width="660" height="40" class="b gw"/>
&lt;text x="100" y="103" class="tiny">LAYER 1 · GATEWAY&lt;/text>
&lt;text x="410" y="120" text-anchor="middle" class="sm">Envoy AI Gateway · routing by body.model · JWT (Defguard) · rate-limit per tenant · trace_id injection&lt;/text>
&lt;!-- Inference + Embeddings + Reranker -->
&lt;rect x="80" y="146" width="320" height="100" class="b llm"/>
&lt;text x="100" y="163" class="tiny">LAYER 2 · LLM INFERENCE&lt;/text>
&lt;rect x="100" y="174" width="280" height="28" class="b" fill="#fff4e0"/>
&lt;text x="240" y="192" text-anchor="middle" class="sm">vLLM · general LLM 70B FP8 · TP=4 · multi-LoRA&lt;/text>
&lt;rect x="100" y="208" width="280" height="28" class="b" fill="#fff4e0"/>
&lt;text x="240" y="226" text-anchor="middle" class="sm">vLLM · code LLM 32B FP8 · TP=2 · structured out.&lt;/text>
&lt;rect x="420" y="146" width="320" height="100" class="b emb"/>
&lt;text x="440" y="163" class="tiny">LAYER 3 · EMBEDDINGS + RERANKER&lt;/text>
&lt;rect x="440" y="174" width="280" height="28" class="b" fill="#fffae8"/>
&lt;text x="580" y="192" text-anchor="middle" class="sm">Infinity · multilingual-e5-large 1024d · ×2&lt;/text>
&lt;rect x="440" y="208" width="280" height="28" class="b" fill="#fffae8"/>
&lt;text x="580" y="226" text-anchor="middle" class="sm">TEI · bge-reranker-v2-m3 (top-50 rerank)&lt;/text>
&lt;path class="arr" d="M250,126 L250,146"/>
&lt;path class="arr" d="M570,126 L570,146"/>
&lt;!-- Vector store + storage -->
&lt;rect x="80" y="266" width="660" height="80" class="b data"/>
&lt;text x="100" y="283" class="tiny">LAYER 4 · VECTOR STORE + RELATIONAL DATA + STORAGE&lt;/text>
&lt;rect x="100" y="294" width="160" height="40" class="b" fill="#eef3fb"/>
&lt;text x="180" y="319" text-anchor="middle" class="sm">Qdrant (HNSW)&lt;/text>
&lt;rect x="280" y="294" width="160" height="40" class="b" fill="#eef3fb"/>
&lt;text x="360" y="319" text-anchor="middle" class="sm">PostgreSQL (CNPG)&lt;/text>
&lt;rect x="460" y="294" width="160" height="40" class="b" fill="#eef3fb"/>
&lt;text x="540" y="319" text-anchor="middle" class="sm">MinIO (weights · adapters)&lt;/text>
&lt;rect x="640" y="294" width="100" height="40" class="b" fill="#eef3fb"/>
&lt;text x="690" y="319" text-anchor="middle" class="sm">Redis&lt;/text>
&lt;path class="arr" d="M580,246 L580,266"/>
&lt;!-- Observability -->
&lt;rect x="80" y="366" width="660" height="60" class="b obs"/>
&lt;text x="100" y="383" class="tiny">LAYER 5 · OBSERVABILITY (LLM-aware traces · metrics · flow logs)&lt;/text>
&lt;rect x="100" y="394" width="150" height="24" class="b" fill="#eaf5e2"/>
&lt;text x="175" y="411" text-anchor="middle" class="sm">Langfuse&lt;/text>
&lt;rect x="260" y="394" width="150" height="24" class="b" fill="#eaf5e2"/>
&lt;text x="335" y="411" text-anchor="middle" class="sm">OTel Collector&lt;/text>
&lt;rect x="420" y="394" width="150" height="24" class="b" fill="#eaf5e2"/>
&lt;text x="495" y="411" text-anchor="middle" class="sm">VictoriaMetrics + Grafana&lt;/text>
&lt;rect x="580" y="394" width="160" height="24" class="b" fill="#eaf5e2"/>
&lt;text x="660" y="411" text-anchor="middle" class="sm">Hubble + DCGM Exporter&lt;/text>
&lt;!-- OTel lines from layers to observability -->
&lt;path class="otel" d="M740,106 L770,106 L770,396 L740,396"/>
&lt;path class="otel" d="M400,196 L770,196" opacity="0.6"/>
&lt;path class="otel" d="M400,310 L770,310" opacity="0.6"/>
&lt;!-- GitOps control plane -->
&lt;rect x="80" y="446" width="380" height="40" class="b ctrl"/>
&lt;text x="100" y="463" class="tiny">LAYER 6 · GITOPS CONTROL PLANE&lt;/text>
&lt;text x="270" y="479" text-anchor="middle" class="sm">Forgejo → Flux → cert-manager · External Secrets · Kyverno&lt;/text>
&lt;!-- Dependency tracking -->
&lt;rect x="480" y="446" width="260" height="40" class="b dep"/>
&lt;text x="500" y="463" class="tiny">LAYER 7 · DEPENDENCY TRACKING&lt;/text>
&lt;text x="610" y="479" text-anchor="middle" class="sm">Hubble flows · Otterize intents&lt;/text>
&lt;!-- Control plane reconciliation → all layers -->
&lt;path class="ctrlarr" d="M270,446 L270,430 L40,430 L40,106 L80,106"/>
&lt;path class="ctrlarr" d="M610,446 L610,430 L780,430 L780,106 L740,106"/>
&lt;text x="60" y="505" class="tiny">Request flow&lt;/text>
&lt;line x1="155" y1="502" x2="180" y2="502" stroke="#666" stroke-width="1.4"/>
&lt;text x="240" y="505" class="tiny">OTel telemetry&lt;/text>
&lt;line x1="335" y1="502" x2="360" y2="502" stroke="#1a73e8" stroke-width="1.4" stroke-dasharray="3 2"/>
&lt;text x="430" y="505" class="tiny">GitOps reconciliation&lt;/text>
&lt;line x1="555" y1="502" x2="580" y2="502" stroke="#c66" stroke-width="1.2" stroke-dasharray="5 3"/>
&lt;/svg>
&lt;/div>
&lt;p>The solid lines are the request path: client → gateway → inference engine → vector store/embeddings → response. The dashed blue lines are telemetry: each component emits OTel to the collector, which routes traces to Langfuse, metrics to VictoriaMetrics and logs to Loki. The dashed red lines are reconciliation: the GitOps control plane keeps any layer in its declared state and warns of divergence.&lt;/p>
&lt;p>The diagram is not decorative: every arrow is a &lt;strong>stable contract&lt;/strong> between two layers. If one layer changes (vLLM → SGLang, multilingual-e5 → bge-m3, Qdrant → pgvector), the arrows hold. That stability of contracts is the architectural property that lets a team migrate components without breaking downstream apps.&lt;/p>
&lt;h2 id="typical-design-decisions-that-break-the-stack">Typical design decisions that break the stack&lt;/h2>
&lt;p>A short list of mistakes seen repeatedly in stacks that looked well designed on paper:&lt;/p>
&lt;p>&lt;strong>1. Coupling the client SDK to the inference engine.&lt;/strong> Dropping the gateway because &amp;ldquo;vLLM already speaks OpenAI-compatible&amp;rdquo; works on day one and hurts the day you have to add a fallback, a canary or a second model.&lt;/p>
&lt;p>&lt;strong>2. Sharing the LLM and embeddings endpoint.&lt;/strong> A &lt;code>qwen2.5-32b-Instruct&lt;/code> is chat-only; &lt;code>BadRequestError: &amp;quot;The model does not support Embeddings API&amp;quot;&lt;/code> is the cry of a design that confused the two layers.&lt;/p>
&lt;p>&lt;strong>3. Reusing the vector store collection when changing the embeddings model.&lt;/strong> Different dimensions do not allow &lt;code>upsert&lt;/code>. Versioning the collection by &lt;code>(model, dim, version)&lt;/code> is ugly but it saves the day of the change.&lt;/p>
&lt;p>&lt;strong>4. A &lt;code>try/except&lt;/code> wrapping an entire pipeline with the wrapper&amp;rsquo;s label.&lt;/strong> The log lies because the label is lexical, not causal. Every stage in its own &lt;code>try/except&lt;/code> with its own label and its own metric.&lt;/p>
&lt;p>&lt;strong>5. &lt;code>status: completed&lt;/code> with errors.&lt;/strong> The pipeline has to distinguish &lt;code>completed&lt;/code>, &lt;code>completed_with_errors&lt;/code> and &lt;code>failed&lt;/code>, and the alerting has to fire on the last two. Without this, observability exists in theory and does not warn in practice.&lt;/p>
&lt;p>&lt;strong>6. Images with a mutable tag.&lt;/strong> &lt;code>:latest&lt;/code> and &lt;code>:main&lt;/code> are not tags, they are aliases. Without a sha digest there is no reproducibility and no auditable SBOM.&lt;/p>
&lt;p>&lt;strong>7. Decommissioning a Service without a pre-decom check.&lt;/strong> The three-grep check (declarative + observed + live) takes two minutes and costs six days of incident when it is skipped.&lt;/p>
&lt;p>&lt;strong>8. Default &lt;code>limits.memory&lt;/code> on pods that load models.&lt;/strong> A sidecar loading &lt;code>sentence-transformers + torch + tokenizer&lt;/code> needs 2–4 GB; with &lt;code>limits.memory: 1Gi&lt;/code> you hit OOM on the first pod restart, and sometimes with no alert because the liveness probe answers through another route.&lt;/p>
&lt;p>All of them are variants of the same principle: the stack does not fail in its most expensive layer (inference, where nobody underestimates the cost), it fails in the cheap, boring layers (gateway, observability, GitOps, dependency tracking) where it is tempting to save.&lt;/p>
&lt;h2 id="applied-to-typical-on-premise-hardware-a-4h100-sxm-cluster">Applied to typical on-premise hardware: a 4×H100 SXM cluster&lt;/h2>
&lt;p>On the generic reference cluster (4×H100 SXM 80 GB, NVLink, 640 GB RAM), the suggested split into pods:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-text" data-lang="text">&lt;span class="line">&lt;span class="cl">gpu-node-01 (4×H100 SXM, intra-node NVLink)
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">├── vllm-llm-general (TP=4) ~130 GB VRAM (4 GPUs)
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">└── (shares GPUs with the multi-LoRA pool on the same deployment)
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">gpu-node-02 (4×H100 SXM, second node)
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">├── vllm-llm-code (TP=2) ~60 GB VRAM (2 GPUs)
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">├── infinity-embeddings (×2) ~16 GB VRAM (shared on 1 GPU with optional MIG)
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">├── tei-reranker ~4 GB VRAM (cohabitant)
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">└── failover reserve ~120 GB VRAM free for canary / standby
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Not everything fits comfortably on a single node; two nodes with two H100 SXM each would be enough for the conservative setup, and the rest of the cluster (CPU-bound: gateway, vector store, observability, control plane) runs on GPU-free nodes.&lt;/p>
&lt;p>The operational rule: &lt;strong>inference concentrates&lt;/strong>, the rest of the stack distributes. Concentrating inference maximises the use of NVLink (cross-GPU tensor parallel without going through PCIe); distributing the rest prevents an event on the GPU node from taking the control plane down with it.&lt;/p>
&lt;p>An even more conservative configuration, for SMEs with a single 4×H100 SXM node as a starting point, serves the general LLM (TP=4) and embeddings/reranker cohabiting with MIG (Multi-Instance GPU to partition an H100 into hardware-isolated slices). The code LLM is deferred to a second phase. It is viable and cost-aware; what is not viable is doing without layers 5, 6 and 7.&lt;/p>
&lt;h2 id="what-we-have-not-covered-upcoming-posts">What we have not covered (upcoming posts)&lt;/h2>
&lt;p>This post focuses on the &lt;strong>static design&lt;/strong> of the stack. Some pieces still deserve an article of their own:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>The multi-site active/standby plane&lt;/strong>: Cilium Cluster Mesh, cross-cluster Qdrant replication, realistic RTO/RPO, when active-active pays off and when it does not.&lt;/li>
&lt;li>&lt;strong>The continuous fine-tuning plane&lt;/strong>: how the LoRA pipeline closes the loop from production feedback to a promoted adapter, in the spirit of the &lt;a href="https://blog.lo0.es/en/posts/retrain-closing-the-loop-feedback-dataset-adapter/">retrain post&lt;/a>.&lt;/li>
&lt;li>&lt;strong>The safety/guardrails plane&lt;/strong>: where Llama Guard fits, Presidio for PII and XGrammar for guaranteed structured output, connected to layer 1, the gateway.&lt;/li>
&lt;li>&lt;strong>The cost plane&lt;/strong>: &lt;code>gen_ai.usage.*&lt;/code> instrumentation at tenant and model level, tokens/euro dashboards, GPU elasticity decisions via KEDA.&lt;/li>
&lt;li>&lt;strong>The compliance plane&lt;/strong>: how the stack maps to ENS High, NIS2 and ISO/IEC 42001 without turning the deployment into a compliance exercise that paralyses delivery.&lt;/li>
&lt;li>&lt;strong>Below the engine&lt;/strong>: the mini-series that opens the stack&amp;rsquo;s basement — &lt;a href="https://blog.lo0.es/en/posts/nvlink-nvswitch-nccl-cable-every-token-crosses/">the interconnect (NVLink/NCCL)&lt;/a>, &lt;a href="https://blog.lo0.es/en/posts/numa-hugepages-cpu-isolation-gpu-waits-kernel/">the host (NUMA, hugepages, CPU isolation)&lt;/a> and &lt;a href="https://blog.lo0.es/en/posts/cpu-memory-topology-manager-rke2/">the RKE2 resource managers&lt;/a> that pin each pod to the right NUMA node.&lt;/li>
&lt;/ul>
&lt;p>Each one falls into a different series on the blog and will be covered with the same discipline: a concrete piece, a justified decision, the typical mistake seen in practice.&lt;/p>
&lt;h2 id="references">References&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/six-stage-llmops-pipeline-architecture-and-deep-dive/">The six-stage LLMOps pipeline&lt;/a> — the general framework this stack operates in.&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> — a card per tool in the stack.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/anatomy-llm-request-production-tour-six-stages/">Anatomy of an LLM request in production&lt;/a> — the same architecture seen from the perspective of an individual request.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/parallel-catalogue-llmops-stages-open-source-hyperscalers/">OSS vs hyperscalers in LLMOps&lt;/a> — a comparison with the AWS/Azure/GCP stacks.&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 backbone of layer 5.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/multi-lora-serving-shared-base-concurrent-adapters-sgmv-kernel/">Multi-LoRA serving&lt;/a> — the piece that gives per-tenant customisation without replicating the base.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/rag-corpus-curation-active-librarian/">RAG corpus curation&lt;/a> and &lt;a href="https://blog.lo0.es/en/posts/reranker-hybrid-retrieval-five-chunks-llm-reads/">Reranker and hybrid retrieval&lt;/a> — everything that goes into layers 3-4 so that RAG does not degrade.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/quantization-llm-inference-fp8-int4-gguf/">Quantisation for LLM inference&lt;/a> — the reason for the FP8 W8A8 in the sizing.&lt;/li>
&lt;/ul>
&lt;h3 id="relevant-official-documentation">Relevant official documentation&lt;/h3>
&lt;ul>
&lt;li>vLLM Production Stack — &lt;a href="https://docs.vllm.ai/">docs.vllm.ai&lt;/a>&lt;/li>
&lt;li>SGLang RadixAttention — &lt;a href="https://github.com/sgl-project/sglang">github.com/sgl-project/sglang&lt;/a>&lt;/li>
&lt;li>Envoy AI Gateway — &lt;a href="https://aigateway.envoyproxy.io/">aigateway.envoyproxy.io&lt;/a>&lt;/li>
&lt;li>Langfuse self-hosted — &lt;a href="https://langfuse.com/docs/self-hosting">langfuse.com/docs/self-hosting&lt;/a>&lt;/li>
&lt;li>OpenTelemetry Semantic Conventions for GenAI — &lt;a href="https://opentelemetry.io/docs/specs/semconv/gen-ai/">opentelemetry.io/docs/specs/semconv/gen-ai&lt;/a>&lt;/li>
&lt;li>Hubble flow observability — &lt;a href="https://docs.cilium.io/en/stable/observability/hubble/">docs.cilium.io/en/stable/observability/hubble&lt;/a>&lt;/li>
&lt;li>Otterize intent-based access — &lt;a href="https://docs.otterize.com/">docs.otterize.com&lt;/a>&lt;/li>
&lt;li>Flux GitOps toolkit — &lt;a href="https://fluxcd.io/">fluxcd.io&lt;/a>&lt;/li>
&lt;/ul></description></item><item><title>LLM tracing with OpenTelemetry GenAI: the aircraft black box the field stabilised in 2026</title><link>https://blog.lo0.es/en/posts/llm-tracing-opentelemetry-genai/</link><pubDate>Wed, 27 May 2026 11:00:00 +0200</pubDate><guid>https://blog.lo0.es/en/posts/llm-tracing-opentelemetry-genai/</guid><description>&lt;blockquote>
&lt;p>This post complements the one on &lt;a href="https://blog.lo0.es/en/posts/prompt-versioning-contract-five-word-change/">Prompt versioning with Langfuse and MLflow&lt;/a>, where &lt;code>prompt_id@version&lt;/code> appeared as a span attribute without the whole pipeline being explained, and the one on &lt;a href="https://blog.lo0.es/en/posts/evals-llm-layer-after-tracing/">Evals for LLMs&lt;/a>, which distinguishes tracing from eval. Here we go inside tracing itself.&lt;/p>
&lt;/blockquote>
&lt;h2 id="tldr">TL;DR&lt;/h2>
&lt;p>OpenTelemetry GenAI, the &lt;code>gen_ai.*&lt;/code> and &lt;code>mcp.*&lt;/code> semantic conventions, have been &lt;strong>stable since late 2025&lt;/strong>, and as of May 2026 they are the substrate on which the whole ecosystem (Langfuse, Phoenix, LangSmith, Braintrust, Arize, Honeycomb, Datadog, New Relic) builds its LLM observability. That means your team instruments once, against OTel, and picks a backend afterwards; not the other way round. The canonical pipeline is &lt;strong>application → OTel SDK (OpenLLMetry or openinference) → OTel Collector → backend(s)&lt;/strong>, with two-layer sampling (head-based at 1-5 % to keep volume viable, tail-based at 100 % over errors and high latencies). This post takes apart the exact attributes you have to fill in on each span (&lt;code>gen_ai.system&lt;/code>, &lt;code>request.model&lt;/code>, &lt;code>usage.input_tokens&lt;/code>, and so on), the operational difference between the three usual SDKs, and the anatomy of a real chat-with-RAG-and-tool trace with all its child spans.&lt;/p>
&lt;h2 id="you-are-here-observe">You are here: OBSERVE&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;style>.box{stroke:#444;stroke-width:1.4;rx:6}.active{fill:#c9a8e9;stroke-width:3}.idle{fill:#f4f4f4}.lbl{font:600 12px sans-serif;fill:#222}.arr{stroke:#666;stroke-width:1.4;fill:none;marker-end:url(#otm)}.cyc{stroke:#888;stroke-width:1.2;fill:none;stroke-dasharray:4 2;marker-end:url(#otm)}&lt;/style>
&lt;defs>&lt;marker id="otm" 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" class="lbl">You are here: OBSERVE · tracing with OpenTelemetry GenAI&lt;/text>
&lt;rect x="30" y="35" width="110" height="35" class="box idle"/>&lt;text x="85" y="58" text-anchor="middle" class="lbl">1 · Data&lt;/text>
&lt;rect x="155" y="35" width="110" height="35" class="box idle"/>&lt;text x="210" y="58" text-anchor="middle" class="lbl">2 · Tune&lt;/text>
&lt;rect x="280" y="35" width="110" height="35" class="box idle"/>&lt;text x="335" y="58" text-anchor="middle" class="lbl">3 · Eval&lt;/text>
&lt;rect x="405" y="35" width="110" height="35" class="box idle"/>&lt;text x="460" y="58" text-anchor="middle" class="lbl">4 · Deploy&lt;/text>
&lt;rect x="530" y="35" width="110" height="35" class="box active"/>&lt;text x="585" y="58" text-anchor="middle" class="lbl">5 · Observe&lt;/text>
&lt;rect x="655" y="35" width="110" height="35" class="box idle"/>&lt;text x="710" y="58" text-anchor="middle" class="lbl">6 · Retrain&lt;/text>
&lt;path class="arr" d="M140,52 L155,52"/>&lt;path class="arr" d="M265,52 L280,52"/>&lt;path class="arr" d="M390,52 L405,52"/>&lt;path class="arr" d="M515,52 L530,52"/>&lt;path class="arr" d="M640,52 L655,52"/>
&lt;path class="cyc" d="M710,72 L710,82 L85,82 L85,72"/>
&lt;/svg>
&lt;/div>
&lt;h2 id="the-analogy-the-aircraft-black-box">The analogy: the aircraft black box&lt;/h2>
&lt;p>An aircraft black box does three things at once:&lt;/p>
&lt;ol>
&lt;li>It records everything without anyone having to ask for it explicitly. Every flight parameter, every cockpit conversation, every action by the pilots is logged with a precise timestamp and a standardised format.&lt;/li>
&lt;li>It uses a format everybody knows. Any air accident investigator anywhere in the world can read the black box of any manufacturer, because the schema is agreed between all of them. There is no &amp;ldquo;Boeing black box&amp;rdquo; and &amp;ldquo;Airbus black box&amp;rdquo;: there is one single black box.&lt;/li>
&lt;li>It survives the event: it is designed on the assumption that the aircraft may go down. It does not depend on the aircraft itself to be stored or to be read.&lt;/li>
&lt;/ol>
&lt;p>LLM tracing with OTel GenAI does exactly those three things:&lt;/p>
&lt;ol>
&lt;li>It records everything automatically. Every model call, every agent step, every tool call, every RAG retrieval ends up as a span, a unit of work with a start, an end, attributes and a parent/child relationship with other spans.&lt;/li>
&lt;li>It uses a common format. The attributes &lt;code>gen_ai.request.model&lt;/code>, &lt;code>gen_ai.usage.input_tokens&lt;/code>, &lt;code>gen_ai.response.finish_reasons&lt;/code> and the rest are &lt;strong>agreed by the OpenTelemetry GenAI working group&lt;/strong> and every backend respects them.&lt;/li>
&lt;li>It survives the backend. Spans do not travel straight to Langfuse: they travel to an intermediate &lt;strong>OTel Collector&lt;/strong> that handles buffering, sampling and fan-out to 1, 2 or N backends. If Langfuse goes down, the Collector holds the spans.&lt;/li>
&lt;/ol>
&lt;p>The analogy is not decorative: it is the only way to understand why OTel GenAI beat the ecosystem of proprietary SDKs that every vendor shipped in 2023-2024. &lt;strong>The standardised black box beats the proprietary recorder, every time.&lt;/strong>&lt;/p>
&lt;h2 id="why-otel-genai-exists-and-why-it-won">Why OTel GenAI exists (and why it won)&lt;/h2>
&lt;p>The state of things at the end of 2024 was broken: every vendor shipped its own SDK with its own attribute names. LangSmith called it &lt;code>model_name&lt;/code>, OpenLLMetry called it &lt;code>llm.model&lt;/code>, Helicone called it &lt;code>model&lt;/code>. If you wanted to change observability provider, you re-instrumented the whole application. For serious teams, that was unacceptable.&lt;/p>
&lt;p>OpenTelemetry already had a framework to solve this: the &lt;strong>semantic conventions&lt;/strong>. A working group (the OpenTelemetry GenAI SIG) proposed a schema. In 2025 it reached &amp;ldquo;Stable&amp;rdquo; for the attributes of the basic modality (chat completions + tool calls), and in 2026 it also covers multimodal, embeddings and reasoning modalities.&lt;/p>
&lt;div class="diagram" style="max-width:760px;margin:1.5rem auto;">
&lt;svg viewBox="0 0 760 290" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="OTel GenAI pipeline">
&lt;style>
.bx{fill:#f8f8f8;stroke:#444;stroke-width:1.4;rx:8}
.bs{fill:#ffe6d6}
.bc{fill:#d6eaff}
.bb{fill:#d9f5d6}
.bm{fill:#fff5b0}
.t{font:700 13px sans-serif;fill:#222}
.s{font:400 11px sans-serif;fill:#555}
.h{font:700 14px sans-serif;fill:#222}
.ar{stroke:#666;stroke-width:1.6;fill:none;marker-end:url(#ot1)}
.aw{stroke:#27ae60;stroke-width:1.8;fill:none;marker-end:url(#ot2)}
&lt;/style>
&lt;defs>
&lt;marker id="ot1" 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;marker id="ot2" 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="#27ae60"/>&lt;/marker>
&lt;/defs>
&lt;text x="380" y="22" text-anchor="middle" class="h">OTel GenAI pipeline: instrument once, choose the backend later&lt;/text>
&lt;rect x="30" y="50" width="160" height="80" class="bx bs"/>
&lt;text x="110" y="74" text-anchor="middle" class="t">Application&lt;/text>
&lt;text x="110" y="92" text-anchor="middle" class="s">Python / Node / Go&lt;/text>
&lt;text x="110" y="108" text-anchor="middle" class="s">+ OpenLLMetry&lt;/text>
&lt;text x="110" y="124" text-anchor="middle" class="s">or openinference SDK&lt;/text>
&lt;rect x="230" y="50" width="160" height="80" class="bx bc"/>
&lt;text x="310" y="74" text-anchor="middle" class="t">OTel Collector&lt;/text>
&lt;text x="310" y="92" text-anchor="middle" class="s">buffering + sampling +&lt;/text>
&lt;text x="310" y="108" text-anchor="middle" class="s">batching + retry +&lt;/text>
&lt;text x="310" y="124" text-anchor="middle" class="s">routing per exporter&lt;/text>
&lt;rect x="430" y="20" width="160" height="40" class="bx bb"/>
&lt;text x="510" y="38" text-anchor="middle" class="t">Langfuse&lt;/text>
&lt;text x="510" y="52" text-anchor="middle" class="s">LLM-first UI, eval, prompts&lt;/text>
&lt;rect x="430" y="80" width="160" height="40" class="bx bb"/>
&lt;text x="510" y="98" text-anchor="middle" class="t">Tempo / Jaeger&lt;/text>
&lt;text x="510" y="112" text-anchor="middle" class="s">distributed tracing infra&lt;/text>
&lt;rect x="430" y="140" width="160" height="40" class="bx bm"/>
&lt;text x="510" y="158" text-anchor="middle" class="t">Prometheus&lt;/text>
&lt;text x="510" y="172" text-anchor="middle" class="s">aggregated metrics&lt;/text>
&lt;rect x="430" y="200" width="160" height="40" class="bx bm"/>
&lt;text x="510" y="218" text-anchor="middle" class="t">ClickHouse / OpenSearch&lt;/text>
&lt;text x="510" y="232" text-anchor="middle" class="s">log and event storage&lt;/text>
&lt;rect x="630" y="80" width="100" height="40" class="bx bb"/>
&lt;text x="680" y="98" text-anchor="middle" class="t">Phoenix&lt;/text>
&lt;text x="680" y="112" text-anchor="middle" class="s">optional, eval-first&lt;/text>
&lt;rect x="630" y="140" width="100" height="40" class="bx bb"/>
&lt;text x="680" y="158" text-anchor="middle" class="t">Datadog/NR&lt;/text>
&lt;text x="680" y="172" text-anchor="middle" class="s">infra-side&lt;/text>
&lt;path class="ar" d="M190,90 L230,90"/>
&lt;path class="ar" d="M390,90 L430,40"/>
&lt;path class="ar" d="M390,90 L430,100"/>
&lt;path class="ar" d="M390,90 L430,160"/>
&lt;path class="ar" d="M390,100 L430,220"/>
&lt;path class="aw" d="M590,100 L630,100"/>
&lt;path class="aw" d="M590,160 L630,160"/>
&lt;text x="110" y="170" text-anchor="middle" class="s" fill="#c0392b">the SDK adds spans automatically&lt;/text>
&lt;text x="110" y="184" text-anchor="middle" class="s" fill="#c0392b">with gen_ai.* attributes&lt;/text>
&lt;text x="310" y="170" text-anchor="middle" class="s" fill="#27ae60">you switch backend by touching only&lt;/text>
&lt;text x="310" y="184" text-anchor="middle" class="s" fill="#27ae60">the Collector's exporter&lt;/text>
&lt;text x="380" y="265" text-anchor="middle" class="s" font-style="italic">If Langfuse goes down, the Collector holds the spans and re-emits them when it returns.&lt;/text>
&lt;/svg>
&lt;/div>
&lt;p>What changes in practice:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Before (proprietary SDKs)&lt;/strong>: you move from Langfuse to Phoenix → you re-instrument, re-deploy everything, lose the history.&lt;/li>
&lt;li>&lt;strong>Now (OTel)&lt;/strong>: you move from Langfuse to Phoenix → you edit the exporter in &lt;code>otel-collector-config.yaml&lt;/code>, reload the Collector, and the new spans go to the new destination without touching a line of application code.&lt;/li>
&lt;/ul>
&lt;h2 id="the-canonical-gen_ai-attributes">The canonical &lt;code>gen_ai.*&lt;/code> attributes&lt;/h2>
&lt;p>As of May 2026 the stable attributes, that is, the ones that are not going to change, are those in the table. There are more in &amp;ldquo;experimental&amp;rdquo; state (multimodal, reasoning) and others in &amp;ldquo;deprecated&amp;rdquo; (kept for compatibility).&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Attribute&lt;/th>
&lt;th>Meaning&lt;/th>
&lt;th>Example&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>&lt;code>gen_ai.system&lt;/code>&lt;/td>
&lt;td>Provider family&lt;/td>
&lt;td>&lt;code>openai&lt;/code>, &lt;code>anthropic&lt;/code>, &lt;code>vllm&lt;/code>, &lt;code>huggingface&lt;/code>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>gen_ai.request.model&lt;/code>&lt;/td>
&lt;td>Model requested&lt;/td>
&lt;td>&lt;code>gpt-4o&lt;/code>, &lt;code>meta-llama/Llama-3.1-8B-Instruct&lt;/code>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>gen_ai.request.temperature&lt;/code>&lt;/td>
&lt;td>Sampling temp&lt;/td>
&lt;td>&lt;code>0.7&lt;/code>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>gen_ai.request.max_tokens&lt;/code>&lt;/td>
&lt;td>Output cap&lt;/td>
&lt;td>&lt;code>1024&lt;/code>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>gen_ai.request.top_p&lt;/code>&lt;/td>
&lt;td>Nucleus sampling&lt;/td>
&lt;td>&lt;code>0.95&lt;/code>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>gen_ai.response.model&lt;/code>&lt;/td>
&lt;td>Model that answered (may differ)&lt;/td>
&lt;td>&lt;code>gpt-4o-2024-08-06&lt;/code>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>gen_ai.response.id&lt;/code>&lt;/td>
&lt;td>ID of the provider&amp;rsquo;s response&lt;/td>
&lt;td>&lt;code>chatcmpl-9xY...&lt;/code>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>gen_ai.response.finish_reasons&lt;/code>&lt;/td>
&lt;td>Finish reasons&lt;/td>
&lt;td>&lt;code>[&amp;quot;stop&amp;quot;]&lt;/code>, &lt;code>[&amp;quot;length&amp;quot;]&lt;/code>, &lt;code>[&amp;quot;tool_calls&amp;quot;]&lt;/code>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>gen_ai.usage.input_tokens&lt;/code>&lt;/td>
&lt;td>Input tokens&lt;/td>
&lt;td>&lt;code>1247&lt;/code>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>gen_ai.usage.output_tokens&lt;/code>&lt;/td>
&lt;td>Output tokens&lt;/td>
&lt;td>&lt;code>412&lt;/code>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>gen_ai.operation.name&lt;/code>&lt;/td>
&lt;td>Operation type&lt;/td>
&lt;td>&lt;code>chat&lt;/code>, &lt;code>text_completion&lt;/code>, &lt;code>embeddings&lt;/code>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>gen_ai.prompt.id&lt;/code>&lt;/td>
&lt;td>ID of the versioned prompt&lt;/td>
&lt;td>&lt;code>customer_support_v3&lt;/code>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>gen_ai.prompt.version&lt;/code>&lt;/td>
&lt;td>Specific version&lt;/td>
&lt;td>&lt;code>14&lt;/code>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>gen_ai.prompt.label&lt;/code>&lt;/td>
&lt;td>Semantic label&lt;/td>
&lt;td>&lt;code>production&lt;/code>, &lt;code>staging&lt;/code>, &lt;code>canary&lt;/code>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>gen_ai.conversation.id&lt;/code>&lt;/td>
&lt;td>Multiturn conversation ID&lt;/td>
&lt;td>&lt;code>session_abc123&lt;/code>&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>And for tool calls (a function invoked by the model), the &lt;code>gen_ai.tool.*&lt;/code> attributes:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Attribute&lt;/th>
&lt;th>Meaning&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>&lt;code>gen_ai.tool.call.id&lt;/code>&lt;/td>
&lt;td>ID of the specific tool call&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>gen_ai.tool.name&lt;/code>&lt;/td>
&lt;td>Name of the tool&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>gen_ai.tool.type&lt;/code>&lt;/td>
&lt;td>&lt;code>function&lt;/code>, &lt;code>mcp&lt;/code>, and so on&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>gen_ai.tool.description&lt;/code>&lt;/td>
&lt;td>Short description&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>And for tool calls over MCP (Model Context Protocol), the &lt;code>mcp.*&lt;/code> attributes add a specific layer (&lt;code>mcp.server.name&lt;/code>, &lt;code>mcp.method&lt;/code>, &lt;code>mcp.session.id&lt;/code> and others) that coexists with the &lt;code>gen_ai.*&lt;/code> ones.&lt;/p>
&lt;p>&lt;strong>The practical rule&lt;/strong>: if your instrumentation fills in these attributes on every span, the Langfuse dashboard, the Tempo graph and the Prometheus metrics come out &amp;ldquo;for free&amp;rdquo;, because all three know how to read the convention.&lt;/p>
&lt;h2 id="the-canonical-pipeline-sdk--collector--backends">The canonical pipeline: SDK → Collector → backends&lt;/h2>
&lt;p>Three layers, each with its own responsibility:&lt;/p>
&lt;h3 id="1--the-sdk">1 · The SDK&lt;/h3>
&lt;p>Three dominant options in 2026:&lt;/p>
&lt;p>&lt;strong>OpenLLMetry (Traceloop)&lt;/strong> — the most widespread. It automatically instruments the OpenAI Python SDK, the Anthropic SDK, LangChain, LlamaIndex, Haystack, Cohere, Mistral, vLLM and almost any library in the ecosystem. A single line (&lt;code>Traceloop.init()&lt;/code>) instruments everything. Apache 2.0 licence. It maintains its own fork with extensions that are not yet in OTel core, but it exports in line with the convention.&lt;/p>
&lt;p>&lt;strong>openinference (Arize)&lt;/strong> — a direct competitor, also Apache 2.0. Closer to Phoenix (same vendor) but it exports standard OTel. Better instrumentation for LangChain/LlamaIndex/DSPy in some versions; worse for vLLM directly.&lt;/p>
&lt;p>&lt;strong>Langfuse SDK&lt;/strong> — proprietary in form (the API is Langfuse&amp;rsquo;s), but underneath it emits OTel spans. The natural choice if Langfuse is the main backend. It has the best support for &amp;ldquo;session&amp;rdquo; and &amp;ldquo;user&amp;rdquo; linking, concepts that are not yet in OTel core but that Langfuse maps to &lt;code>gen_ai.conversation.id&lt;/code> for compatibility.&lt;/p>
&lt;p>Practical recommendation for May 2026: &lt;strong>OpenLLMetry if you want to stay backend-agnostic&lt;/strong> (you will change provider); &lt;strong>the Langfuse SDK if Langfuse is already your bet&lt;/strong> (you save yourself a mapping). Both produce valid spans that any Collector consumes.&lt;/p>
&lt;h3 id="2--the-otel-collector">2 · The OTel Collector&lt;/h3>
&lt;p>The Collector is the &lt;strong>most important piece&lt;/strong> of the pipeline and the least talked about. Three responsibilities:&lt;/p>
&lt;p>&lt;strong>Buffering&lt;/strong>: if Langfuse or Tempo have a problem, the Collector holds the spans in memory (or on disk with persistence enabled) and re-emits them when the backend returns.&lt;/p>
&lt;p>&lt;strong>Sampling&lt;/strong>: it applies the two sampling layers (next section) without the application knowing anything about it.&lt;/p>
&lt;p>&lt;strong>Routing&lt;/strong>: with the &lt;code>exporters&lt;/code> config it points at one or several backends at once. The usual practice is to &lt;strong>send to Langfuse + Tempo + Prometheus simultaneously&lt;/strong>:&lt;/p>
&lt;ul>
&lt;li>Langfuse for the LLM-first UI (prompts, evals, user sessions).&lt;/li>
&lt;li>Tempo (or Jaeger) for the full distributed context (an LLM span inside an HTTP request that has touched 12 microservices).&lt;/li>
&lt;li>Prometheus for aggregated metrics (P95 latency per model, tokens per second, error rate).&lt;/li>
&lt;/ul>
&lt;p>A representative production fragment of &lt;code>otel-collector-config.yaml&lt;/code>:&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">receivers&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">otlp&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">protocols&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">grpc&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="m">0.0.0.0&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="m">4317&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">http&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="m">0.0.0.0&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="m">4318&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">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">batch&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">timeout&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">1s&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">send_batch_size&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">1024&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">decision_wait&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">10s&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">errors&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="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">slow_traces&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">latency&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">latency&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>{&lt;span class="w"> &lt;/span>&lt;span class="nt">threshold_ms&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">5000&lt;/span>&lt;span class="w"> &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">head_5pct&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="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 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">https://langfuse.internal:3000/api/public/otel/v1&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 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_AUTH}&amp;#34;&lt;/span>&lt;span class="w"> &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">otlp/tempo&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">tempo:4317&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">tls&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>{&lt;span class="w"> &lt;/span>&lt;span class="nt">insecure&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 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">prometheus&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="m">0.0.0.0&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="m">8889&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">service&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">pipelines&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">traces&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">receivers&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="l">otlp]&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">processors&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="l">tail_sampling, batch]&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">exporters&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="l">otlphttp/langfuse, otlp/tempo]&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">metrics&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">receivers&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="l">otlp]&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">processors&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="l">batch]&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">exporters&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="l">prometheus]&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h3 id="3--the-backends">3 · The backends&lt;/h3>
&lt;p>&lt;strong>Langfuse&lt;/strong> is the dominant option for OSS LLM observability (MIT licence, complete suite: tracing + evals + prompts + datasets). Its model assumes OTel &lt;code>trace_id&lt;/code> and &lt;code>span_id&lt;/code> and draws them in the UI with the LLM context expanded (formatted messages, counted tokens, costs calculated from a model→price mapping).&lt;/p>
&lt;p>&lt;strong>Phoenix&lt;/strong> (Arize, ELv2 licence) has a more eval-first focus. Useful if your team comes from classic NLP and wants UMAP embedding visualisation by default.&lt;/p>
&lt;p>&lt;strong>LangSmith&lt;/strong> (LangChain) is proprietary, not OSS. Excellent integration with LangChain/LangGraph; less relevant if you do not use that stack.&lt;/p>
&lt;p>&lt;strong>Tempo / Jaeger&lt;/strong> show the LLM traces &lt;strong>inside the context of the distributed request&lt;/strong>. It is what you need when a customer reports that &amp;ldquo;the page takes 8 seconds&amp;rdquo; and you want to see whether those 8 seconds are the model, the retrieval or the downstream microservice.&lt;/p>
&lt;h2 id="sampling-the-two-layers-that-coexist">Sampling: the two layers that coexist&lt;/h2>
&lt;p>Without sampling, a system at 10 req/s generates about 26 million spans a month. The backend drowns, the cost explodes and most of the spans are redundant (200 similar requests tell you no more than 20).&lt;/p>
&lt;p>The canonical pattern in 2026 is &lt;strong>two combined layers&lt;/strong>:&lt;/p>
&lt;h3 id="head-based-sampling-on-the-way-in">Head-based sampling (on the way in)&lt;/h3>
&lt;p>You decide &lt;strong>before&lt;/strong> the span exists whether you are going to trace it or not, based on a probabilistic decision (1-5 % typically) or on a deterministic rule (always trace if the user is premium, if the model is the new one in canary, and so on).&lt;/p>
&lt;p>Pros: cheap (the span is not generated at all), deterministic.&lt;/p>
&lt;p>Cons: &lt;strong>you can miss infrequent errors&lt;/strong>. If an error happens once in every 1,000 and you trace 1 %, you will see it once in every 100,000, which is no longer debuggable.&lt;/p>
&lt;h3 id="tail-based-sampling-on-the-way-out">Tail-based sampling (on the way out)&lt;/h3>
&lt;p>The complete span is generated and at the end you decide whether to keep it or throw it away. The decision can be:&lt;/p>
&lt;ul>
&lt;li>Keep 100 % of errors.&lt;/li>
&lt;li>Keep 100 % of traces with latency &amp;gt; X ms.&lt;/li>
&lt;li>Keep 100 % of traces that triggered a guardrail.&lt;/li>
&lt;li>Keep a random sample of the rest (1-5 %).&lt;/li>
&lt;/ul>
&lt;p>Pros: it guarantees that you see the errors and the latency outliers. Without it, the most interesting problems are the ones most likely to be left out.&lt;/p>
&lt;p>Cons: you need a buffer, because until the trace finishes you cannot decide. The Collector&amp;rsquo;s &lt;code>tail_sampling&lt;/code> processor keeps the spans in memory for 5-30 seconds until it takes the decision.&lt;/p>
&lt;p>The practical rule in 2026 is to &lt;strong>combine both&lt;/strong>: head-based at 5 % + tail-based capturing 100 % of errors and latencies &amp;gt; 5s. That gives you around 5 % of baseline traffic plus 100 % of what is interesting.&lt;/p>
&lt;h2 id="anatomy-of-a-real-trace">Anatomy of a real trace&lt;/h2>
&lt;p>We are going to take apart the trace of a single user question to a typical chat-with-RAG-and-tool system. The question is &amp;ldquo;what is the balance of my main account?&amp;rdquo;.&lt;/p>
&lt;div class="diagram" style="max-width:760px;margin:1.5rem auto;">
&lt;svg viewBox="0 0 760 360" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Anatomy of a complete LLM trace">
&lt;style>
.tr{fill:#f4f4f4;stroke:#999;stroke-width:1}
.s1{fill:#d6eaff;stroke:#222;stroke-width:1}
.s2{fill:#ffe9d6;stroke:#222;stroke-width:1}
.s3{fill:#d9f5d6;stroke:#222;stroke-width:1}
.s4{fill:#fff5b0;stroke:#222;stroke-width:1}
.s5{fill:#e9d6f5;stroke:#222;stroke-width:1}
.lb{font:600 11px sans-serif;fill:#222}
.sm{font:400 10px sans-serif;fill:#444}
.h{font:700 13px sans-serif;fill:#222}
.tm{font:400 10px sans-serif;fill:#888}
&lt;/style>
&lt;text x="380" y="20" text-anchor="middle" class="h">Trace_id = abc123 · "what is the balance of my main account?"&lt;/text>
&lt;text x="380" y="38" text-anchor="middle" class="tm">total duration: 2,847 ms · 7 spans · 1 trace&lt;/text>
&lt;line x1="60" y1="55" x2="700" y2="55" stroke="#ccc"/>
&lt;text x="60" y="68" class="tm">0 ms&lt;/text>
&lt;text x="700" y="68" text-anchor="end" class="tm">2847 ms&lt;/text>
&lt;rect x="60" y="80" width="640" height="22" class="s1"/>
&lt;text x="70" y="96" class="lb">HTTP POST /chat — root span (app)&lt;/text>
&lt;rect x="80" y="112" width="100" height="20" class="s2"/>
&lt;text x="86" y="126" class="sm">guardrail.input · 38ms&lt;/text>
&lt;rect x="190" y="112" width="220" height="20" class="s3"/>
&lt;text x="196" y="126" class="sm">rag.retrieve · 218ms · BGE-M3 + Qdrant&lt;/text>
&lt;rect x="190" y="136" width="80" height="18" class="s3" opacity="0.7"/>
&lt;text x="195" y="149" class="sm">embed · 28ms&lt;/text>
&lt;rect x="280" y="136" width="125" height="18" class="s3" opacity="0.7"/>
&lt;text x="285" y="149" class="sm">vector_search · 80ms&lt;/text>
&lt;rect x="420" y="112" width="280" height="20" class="s4"/>
&lt;text x="426" y="126" class="lb">gen_ai.chat · 2104ms · llama-3.1-70b-instruct&lt;/text>
&lt;rect x="510" y="136" width="80" height="18" class="s5" opacity="0.85"/>
&lt;text x="515" y="149" class="sm">tool.get_balance · 60ms&lt;/text>
&lt;rect x="600" y="112" width="60" height="20" class="s2"/>
&lt;text x="606" y="126" class="sm">guardrail.output · 28ms&lt;/text>
&lt;line x1="60" y1="170" x2="700" y2="170" stroke="#ccc"/>
&lt;text x="60" y="190" class="h">Key attributes per span&lt;/text>
&lt;text x="60" y="208" class="sm">root: &lt;tspan font-family="monospace">http.method=POST, user.id=42, conversation.id=session_abc&lt;/tspan>&lt;/text>
&lt;text x="60" y="224" class="sm">guardrail.input: &lt;tspan font-family="monospace">gen_ai.tool.name=llm_guard, gen_ai.tool.type=guardrail, gen_ai.guardrail.decision=allow&lt;/tspan>&lt;/text>
&lt;text x="60" y="240" class="sm">rag.retrieve: &lt;tspan font-family="monospace">gen_ai.operation.name=embeddings, gen_ai.request.model=bge-m3, db.system=qdrant&lt;/tspan>&lt;/text>
&lt;text x="60" y="256" class="sm">vector_search: &lt;tspan font-family="monospace">db.operation=query, db.collection=docs_prod, db.qdrant.top_k=20, db.qdrant.score=0.83&lt;/tspan>&lt;/text>
&lt;text x="60" y="272" class="sm">gen_ai.chat: &lt;tspan font-family="monospace">gen_ai.system=vllm, request.model=llama-3.1-70b, usage.input=1247, usage.output=412&lt;/tspan>&lt;/text>
&lt;text x="60" y="288" class="sm"> prompt.id=customer_support_v3, prompt.version=14, prompt.label=production&lt;/text>
&lt;text x="60" y="304" class="sm">tool.get_balance: &lt;tspan font-family="monospace">gen_ai.tool.name=get_balance, gen_ai.tool.type=function, gen_ai.tool.call.id=call_xy&lt;/tspan>&lt;/text>
&lt;text x="60" y="320" class="sm">guardrail.output: &lt;tspan font-family="monospace">gen_ai.tool.name=llama_guard_4, gen_ai.guardrail.decision=allow&lt;/tspan>&lt;/text>
&lt;text x="60" y="344" class="sm" font-style="italic">All the spans share trace_id=abc123. Langfuse draws them as a tree; Tempo as a flat timeline.&lt;/text>
&lt;/svg>
&lt;/div>
&lt;p>Seven spans, one single trace. The information you can exploit:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Latency broken down&lt;/strong>: 38 ms guardrail + 218 ms retrieval + 2104 ms LLM + 60 ms tool + 28 ms guardrail = 2,448 ms (the rest is orchestration overhead). The bottleneck is the LLM (74 % of the time). If the user complains about slowness, you know where to look.&lt;/li>
&lt;li>&lt;strong>Tokens and cost&lt;/strong>: 1,247 input + 412 output. With Llama 3.1 70B on-prem at about 12 W/token of equivalent energy cost, roughly 3.5 c€ per response. Multiplied by volume, that gives you the inference bill.&lt;/li>
&lt;li>&lt;strong>&lt;code>prompt.id+version&lt;/code> travelling along&lt;/strong>: if in a month&amp;rsquo;s time you notice that quality has dropped, you filter by &lt;code>prompt.version&lt;/code> and see whether it coincides with a change to the prompt.&lt;/li>
&lt;li>&lt;strong>Guardrail decisions traced&lt;/strong>: if a guardrail blocked the response, it stays in the trace with &lt;code>gen_ai.guardrail.decision=block&lt;/code> and a reason. ENS audit satisfied.&lt;/li>
&lt;li>&lt;strong>Correlated tool calls&lt;/strong>: &lt;code>tool.get_balance&lt;/code> is a child span of &lt;code>gen_ai.chat&lt;/code>. If the tool failed, you see the error directly in its span; it does not turn up only in separate logs.&lt;/li>
&lt;/ul>
&lt;h2 id="implications-for-on-premise-hardware">Implications for on-premise hardware&lt;/h2>
&lt;p>Tracing is not free, but its overhead, properly sized, is negligible.&lt;/p>
&lt;h3 id="cost-per-span">Cost per span&lt;/h3>
&lt;p>At the SDK level, an LLM span adds around 50-200 µs of overhead (creation, attributes, serialisation). For an LLM call lasting 2 seconds, that is &amp;lt; 0.01 % of the latency.&lt;/p>
&lt;p>At the network level, a typical compressed OTLP span weighs around 1-3 KB. For a system at 100 req/s with 7 spans per request, that is 700 spans/s × 2 KB ≈ 1.4 MB/s to the Collector. Trivial.&lt;/p>
&lt;h3 id="storage">Storage&lt;/h3>
&lt;p>This is where the money goes. Without sampling, a month at 100 req/s is about 18 GB of spans. With head-based at 5 % + tail-based on errors, about 1-2 GB a month. Manageable in any self-hosted ClickHouse or Loki.&lt;/p>
&lt;h3 id="on-an-rtx-4090-24-gb--collector-and-langfuse-on-prem">On an RTX 4090 (24 GB) + Collector and Langfuse on-prem&lt;/h3>
&lt;p>For a demo service or a small single-tenant: Langfuse + Postgres + Clickhouse + Collector on the same host as the model. It uses around 8 GB of RAM and &amp;lt; 1 % of CPU continuously. The 4090 serves the model, the rest lives on the CPU. Minimum viable setup for a startup or a small team.&lt;/p>
&lt;h3 id="on-a-generic-4h100-sxm-cluster--central-observability">On a generic 4×H100 SXM cluster + central observability&lt;/h3>
&lt;p>A dedicated pod for the OTel Collector (a DaemonSet on each node + a central Gateway). Langfuse + ClickHouse + Tempo + Prometheus + Grafana in an &lt;code>observability&lt;/code> namespace separate from the &lt;code>serving&lt;/code> namespace. This is the canonical architecture of any serious ENS/NIS2 production deployment: observability does not share resources with the model.&lt;/p>
&lt;h2 id="what-we-have-not-covered-upcoming-articles">What we have not covered (upcoming articles)&lt;/h2>
&lt;ul>
&lt;li>&lt;strong>LLM metrics (Prometheus): the canonical counters and histograms&lt;/strong> — &lt;code>gen_ai_client_request_duration_seconds&lt;/code>, &lt;code>gen_ai_client_input_tokens_total&lt;/code> and the rest. How Grafana dashboards are built on that base.&lt;/li>
&lt;li>&lt;strong>Guardrails as spans&lt;/strong>: how to model Llama Guard 4, NeMo Guardrails and LLM Guard as child spans of the LLM span, and what attributes they carry. Covered in the &lt;a href="https://blog.lo0.es/en/posts/llm-guardrails-safety-four-lines-of-defence/">post on guardrails&lt;/a> — attributes &lt;code>gen_ai.guardrail.line&lt;/code>, &lt;code>gen_ai.guardrail.detector&lt;/code>, &lt;code>gen_ai.guardrail.category&lt;/code>, &lt;code>gen_ai.guardrail.score&lt;/code>, &lt;code>gen_ai.guardrail.action&lt;/code>.&lt;/li>
&lt;li>&lt;strong>Distributed tracing with MCP&lt;/strong> — &lt;code>traceparent&lt;/code> propagation from the LLM client to the MCP server, &lt;code>mcp.*&lt;/code> attributes, known problems.&lt;/li>
&lt;li>&lt;strong>eBPF for automatic tracing without an SDK&lt;/strong> — Tetragon and Hubble extracting LLM traces without explicit instrumentation, for cases where the code cannot be modified.&lt;/li>
&lt;/ul>
&lt;h2 id="see-also">See also&lt;/h2>
&lt;ul>
&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 &lt;code>prompt_id@version&lt;/code> that travels as &lt;code>gen_ai.prompt.id&lt;/code> in the spans described here.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/evals-llm-layer-after-tracing/">Evals for LLMs: the layer after tracing&lt;/a> — the &amp;ldquo;the judge runs over a sample of the traces&amp;rdquo; pattern rests on this pipeline.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/llm-as-judge-exam-marker-not-oracle/">LLM-as-judge: the exam marker&lt;/a> — the judge consumes traces to evaluate quality continuously. The traces kept by tail-sampling are its input.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/continuous-fine-tuning-production-real-traffic-deployed-adapter/">Continuous fine-tuning in production&lt;/a> — traces are the origin of the fine-tuning dataset. When a user regenerates, that span with &lt;code>gen_ai.response.finish_reasons=[&amp;quot;stop&amp;quot;]&lt;/code> rejected enters the DPO pipeline.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/six-stage-llmops-pipeline-architecture-and-deep-dive/">The six-stage LLMOps pipeline&lt;/a> — the master map where OBSERVE is stage 5.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/llm-guardrails-safety-four-lines-of-defence/">Guardrails and safety in LLMs&lt;/a> — the online safety layer whose decisions are modelled as child spans of the LLM span with &lt;code>gen_ai.guardrail.*&lt;/code> attributes. The traceability of every block / redact / flag is the basis for ENS / NIS2 / EU AI Act audits.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/llm-guard-anatomy-scanners-langfuse-vllm-litellm/">LLM Guard: anatomy and integration with Langfuse&lt;/a> — the concrete tool whose OTel HTTP export described in this post closes the circle: &lt;code>gen_ai.guardrail.*&lt;/code> spans for each scanner executed, ingestable directly by Langfuse via &lt;code>/api/public/otel&lt;/code>.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/iso-42001-aims-on-premise-llm-platform/">ISO/IEC 42001: the operations manual for the AI system&lt;/a> — the OTel + Langfuse tracing described here materialises control A.8 (information to interested parties) of Annex A: per-request traceability, forensic reporting capability and incident notification to users and regulators.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/eu-ai-act-technical-dossier-on-premise-llm-architecture/">EU AI Act: the technical file article by article&lt;/a> — the OTel traceability described here is the canonical materialisation of Arts. 12 + 19 (record-keeping and automatic logs) of the Regulation; a minimum retention of 6 months + WORM + redacted PII are the explicit legal requirements.&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 × 42001 × EU AI Act&lt;/a> — OTel &lt;code>gen_ai.*&lt;/code> spans with the right metadata satisfy &lt;code>op.exp.8 + .10&lt;/code> ENS + A.8.2 ISO 42001 + Arts. 12 + 19 AI Act &lt;strong>in a single piece&lt;/strong> when they are labelled with a common vocabulary.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/gpu-observability-llm-inference-twelve-dcgm-vllm-metrics/">GPU observability for LLM inference&lt;/a> — the other half of LLM observability: the DCGM (hardware) and vLLM (engine) metrics complement the semantic tracing of &lt;code>gen_ai.*&lt;/code> to cover the questions &amp;ldquo;is the GPU healthy?&amp;rdquo; and &amp;ldquo;is it meeting the SLO?&amp;rdquo;.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/langfuse-inside-sorting-centre-bottleneck/">Inside Langfuse: v3 architecture and the 10 backend knobs&lt;/a> — here Langfuse is the destination of the pipeline; there the box is opened: the six services (Web, Worker, Postgres, ClickHouse, Redis, S3), asynchronous ingestion and self-hosted tuning. The two-layer sampling in this post is the &amp;ldquo;knob 0&amp;rdquo; that multiplies the ten in that one.&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> — the precedence chain with which a real backend resolves these attributes, and the scores that do not travel over OTLP.&lt;/li>
&lt;/ul>
&lt;h2 id="references">References&lt;/h2>
&lt;ul>
&lt;li>OpenTelemetry GenAI Semantic Conventions: &lt;a href="https://opentelemetry.io/docs/specs/semconv/gen-ai/">https://opentelemetry.io/docs/specs/semconv/gen-ai/&lt;/a>.&lt;/li>
&lt;li>OpenLLMetry (Traceloop): &lt;a href="https://github.com/traceloop/openllmetry">https://github.com/traceloop/openllmetry&lt;/a>.&lt;/li>
&lt;li>openinference (Arize): &lt;a href="https://github.com/Arize-ai/openinference">https://github.com/Arize-ai/openinference&lt;/a>.&lt;/li>
&lt;li>Langfuse OTel integration: &lt;a href="https://langfuse.com/docs/opentelemetry/get-started">https://langfuse.com/docs/opentelemetry/get-started&lt;/a>.&lt;/li>
&lt;li>OTel Collector tail-sampling processor: &lt;a href="https://github.com/open-telemetry/opentelemetry-collector-contrib/tree/main/processor/tailsamplingprocessor">https://github.com/open-telemetry/opentelemetry-collector-contrib/tree/main/processor/tailsamplingprocessor&lt;/a>.&lt;/li>
&lt;li>Phoenix (Arize) docs: &lt;a href="https://docs.arize.com/phoenix">https://docs.arize.com/phoenix&lt;/a>.&lt;/li>
&lt;li>Model Context Protocol observability (&lt;code>mcp.*&lt;/code> semconv): &lt;a href="https://modelcontextprotocol.io/docs/observability">https://modelcontextprotocol.io/docs/observability&lt;/a>.&lt;/li>
&lt;/ul></description></item><item><title>eBPF in local inference and statistical drift detection: closing the LLM observability loop in 2026</title><link>https://blog.lo0.es/en/posts/ebpf-local-inference-statistical-drift-detection/</link><pubDate>Wed, 20 May 2026 09:00:00 +0200</pubDate><guid>https://blog.lo0.es/en/posts/ebpf-local-inference-statistical-drift-detection/</guid><description>&lt;h2 id="tldr">TL;DR&lt;/h2>
&lt;p>Tracing, evals, guardrails, MCP observability: the layers we have already covered see what is happening right now. What they do not see is what changes silently: the agent that answered well last week and this week, without anyone touching anything, answers slightly worse. What they also do not see is the fine mechanics of local inference: why a llama.cpp on an edge device takes 200 ms when it should take 100, which specific runtime function is the bottleneck. This post closes the two series of the week with the two layers that were missing: eBPF applied to local inference (uprobes on &lt;code>llama.cpp&lt;/code>, &lt;code>vLLM&lt;/code>, &lt;code>libcudart.so&lt;/code>, integrated hardware perf counters, with &amp;lt;4% overhead, formalised in the &lt;a href="https://arxiv.org/abs/2601.20755">ProfInfer 2026&lt;/a> paper, which is to local inference what Hubble is to the network) and statistical analysis of agent flows to detect drift before your user notices it (KS, PSI, MMD, embedding-space clustering, with &lt;a href="https://www.evidentlyai.com/">Evidently AI&lt;/a>, &lt;a href="https://www.nannyml.com/">NannyML&lt;/a> and &lt;a href="https://whylabs.ai/">WhyLabs&lt;/a> as the dominant tools). The three types of LLM drift in 2026, prompt drift, model drift and eval-score drift, demand different tests. The complete stack (tracing, evals, guardrails, MCP observability, eBPF observability, drift detection) forms the loop that any serious agentic system needs in order to operate with a real SLA rather than with hope.&lt;/p>
&lt;blockquote>
&lt;p>This post closes two series: the post-tracing series (&lt;a href="https://blog.lo0.es/en/posts/evals-llm-layer-after-tracing/">Evals&lt;/a>, &lt;a href="https://blog.lo0.es/en/posts/llm-guardrails-safety-four-lines-of-defence/">Guardrails&lt;/a>, &lt;a href="https://blog.lo0.es/en/posts/mcp-deep-observability-opentelemetry/">MCP observability&lt;/a>) and the eBPF series (&lt;a href="https://blog.lo0.es/en/posts/ebpf-zero-to-cilium-kernel-tcp-ip-bypass/">eBPF from zero to Cilium&lt;/a>, &lt;a href="https://blog.lo0.es/en/posts/tetragon-cilium-security-syscalls-kernel/">Tetragon&lt;/a>, &lt;a href="https://blog.lo0.es/en/posts/hubble-ebpf-network-observability-ai-agents/">Hubble&lt;/a>, &lt;a href="https://blog.lo0.es/en/posts/agentsight-llm-tracing-ebpf-zero-instrumentation/">AgentSight&lt;/a>). It brings the two threads together: eBPF applied to the local inference engine plus statistical analysis of the flows that every layer produces.&lt;/p>
&lt;/blockquote>
&lt;h2 id="the-analogy-the-agents-cardiogram">The analogy: the agent&amp;rsquo;s cardiogram&lt;/h2>
&lt;p>A doctor who only looks at acute symptoms, the patient arrives with a high fever and you have to act, is practising reactive medicine. To practise preventive medicine, they need time series: blood pressure every year, cholesterol every six months, an ECG when there is a suspicion. It is not &amp;ldquo;right now&amp;rdquo; information, it is information about how something that should be stable is evolving. When a time series deviates from its baseline, you investigate before it turns into a high fever.&lt;/p>
&lt;p>The LLM observability layers we have seen so far are reactive medicine: tracing tells you what happened in a specific conversation; evals tell you whether that conversation was good; guardrails tell you whether there was a specific threat; MCP observability tells you which tools were invoked and how. They all look at events, not trends.&lt;/p>
&lt;p>Drift detection is preventive medicine. It looks at time series, of prompt embeddings, of evaluation scores, of distributions of generated tokens, and fires alerts when something drifts away from its normality. It does not tell you &amp;ldquo;this answer is bad&amp;rdquo;; it tells you &amp;ldquo;the distribution of prompts over the last 6 hours does not look like the distribution of the last month&amp;rdquo;. There you decide whether to investigate.&lt;/p>
&lt;p>And the other half of the post, eBPF in local inference, is the equivalent of the MRI scanner: once you know there is a problem, it lets you see the inside of the model at a resolution no external wrapper gives you. To see which specific runtime function is slow, which CUDA kernel is the bottleneck, how tokens move through the internal buffers before going out to the client.&lt;/p>
&lt;p>The two together close the loop: the time series detect that something is wrong, the scanner locates where.&lt;/p>
&lt;h2 id="part-1--ebpf-applied-to-local-inference">Part 1 — eBPF applied to local inference&lt;/h2>
&lt;h3 id="why-local-inference-changes-the-game">Why local inference changes the game&lt;/h3>
&lt;p>When the LLM runs locally, vLLM on a Kubernetes node, llama.cpp on an edge device, Ollama on a workstation, MLX on macOS, and not behind an external API, observability changes shape:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>You control the binary&lt;/strong>: you can attach hooks that would otherwise be impossible.&lt;/li>
&lt;li>&lt;strong>The internal buffers exist in accessible RAM&lt;/strong>: the output-token stream, the logits, the KV caches, the scheduler structures are right there, at addresses a uprobe can read.&lt;/li>
&lt;li>&lt;strong>There is no cable to sniff&lt;/strong>: AgentSight&amp;rsquo;s analogy with SSL hooks does not apply because there is no TLS; the model answers you with an in-process function return, not with an HTTPS response.&lt;/li>
&lt;li>&lt;strong>The distance between kernel and model is minimal&lt;/strong>: the CUDA kernels running attention are one syscall deep; eBPF can observe both sides of that boundary with the same tracer.&lt;/li>
&lt;/ul>
&lt;p>This opens up a class of observability that is structurally impossible with LLM-as-a-service (the Anthropic, OpenAI or Vertex APIs). For apps serving inference on-premise or on-edge, an inference cluster, a mobile device, an RTX 4090 server in the rack, it is a new layer.&lt;/p>
&lt;h3 id="profinfer-the-paper-that-formalises-the-pattern">ProfInfer: the paper that formalises the pattern&lt;/h3>
&lt;p>&lt;a href="https://arxiv.org/abs/2601.20755">ProfInfer (arxiv 2601.20755, 2026)&lt;/a> is the reference academic piece that systematises what the ecosystem had been doing ad-hoc. The paper&amp;rsquo;s subtitle says it all: &lt;em>An eBPF-based Fine-Grained LLM Inference Profiler&lt;/em>.&lt;/p>
&lt;p>What it proposes:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Attaching uprobes dynamically&lt;/strong> to runtime functions of engines such as &lt;code>llama.cpp&lt;/code> (and by extension vLLM, Ollama). No recompiling, no modifying the source code. It is like &lt;code>bpftrace&lt;/code> for LLM inference.&lt;/li>
&lt;li>&lt;strong>Combining runtime events with hardware performance counters&lt;/strong>. A uprobe tells you when &lt;code>llama_decode&lt;/code> runs; a hardware counter tells you how many floating-point instructions executed while it was inside. The correlation between the two is what gives the fine resolution.&lt;/li>
&lt;li>&lt;strong>&amp;lt;4% measured overhead&lt;/strong> under real workloads. That is a production-grade cost.&lt;/li>
&lt;li>&lt;strong>Visualisations&lt;/strong> in three views: operators (which tensor operations ran), graphs (how they relate) and timelines (when).&lt;/li>
&lt;/ul>
&lt;p>The paper focuses particularly on models on mobile platforms (Llama served on a Pixel or an iPhone), where classic observability with Prometheus and exported metrics barely exists. But the pattern applies to any local inference.&lt;/p>
&lt;h3 id="where-to-hook-the-map-by-engine">Where to hook: the map by engine&lt;/h3>
&lt;p>Now to the detail of the hooks. The target functions vary by engine:&lt;/p>
&lt;h4 id="llamacpp">llama.cpp&lt;/h4>
&lt;p>&lt;code>llama.cpp&lt;/code> is pure C++, with symbols visible in the binary. The typical hooks:&lt;/p>
&lt;ul>
&lt;li>&lt;code>llama_decode&lt;/code>: the function that runs one inference pass (processes the current batch). Spans for latency per iteration, tokens processed.&lt;/li>
&lt;li>&lt;code>llama_token_to_piece&lt;/code>: converts a token ID to text. A hook here captures the stream of generated tokens before returning to the caller. It is the local equivalent of the SSL uprobes: you see the model&amp;rsquo;s output before it even reaches the consumer.&lt;/li>
&lt;li>&lt;code>llama_get_logits&lt;/code>: reads the logits of the last decode. If you want to record the model&amp;rsquo;s probabilities (not just the chosen token), here.&lt;/li>
&lt;li>&lt;code>ggml_compute_forward_*&lt;/code> (several functions): the operation kernels (matmul, attention, layernorm). Hooks for per-operation profiling.&lt;/li>
&lt;li>&lt;code>ggml_backend_*&lt;/code>: the backend functions (CPU, Metal, CUDA, ROCm). Hooks here break down the cost per device.&lt;/li>
&lt;/ul>
&lt;p>An example with &lt;code>bpftrace&lt;/code>:&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"># Latency and count of llama_decode&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">bpftrace -e &lt;span class="s1">&amp;#39;
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="s1">uprobe:/path/to/llama-server:llama_decode {
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="s1"> @start[tid] = nsecs;
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="s1">}
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="s1">uretprobe:/path/to/llama-server:llama_decode /@start[tid]/ {
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="s1"> @decode_lat = hist((nsecs - @start[tid]) / 1000);
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="s1"> delete(@start[tid]);
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="s1">}
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="s1">&amp;#39;&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Output: a histogram of decode latencies in microseconds. Zero modification to the binary.&lt;/p>
&lt;h4 id="vllm">vLLM&lt;/h4>
&lt;p>vLLM is mostly Python. The C/CUDA symbols live in its native extensions (&lt;code>vllm._C&lt;/code>, &lt;code>vllm._moe_C&lt;/code>). The typical hooks:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>uprobes on &lt;code>vllm._C.*&lt;/code>&lt;/strong> for custom operators (the paged attention kernel, the sampling kernel).&lt;/li>
&lt;li>&lt;strong>uprobes on &lt;code>libcudart.so&lt;/code> and &lt;code>libcuda.so&lt;/code>&lt;/strong> to capture &lt;code>cudaMalloc&lt;/code>, &lt;code>cudaLaunchKernel&lt;/code>, &lt;code>cudaMemcpy&lt;/code>. This maps the cost of host↔device transfers and kernel launches.&lt;/li>
&lt;li>&lt;strong>Python tracepoints with &lt;code>bpftrace&lt;/code> over &lt;code>usdt&lt;/code> points&lt;/strong>: vLLM does not expose native static tracepoints, but they can be placed with USDT (&lt;code>dtrace&lt;/code> style) at strategic points in the scheduler.&lt;/li>
&lt;/ul>
&lt;p>vLLM also exposes native Prometheus metrics (&lt;code>vllm:num_requests_running&lt;/code>, &lt;code>vllm:gpu_cache_usage_perc&lt;/code>, and so on). The added value of the eBPF approach is going down from the scheduler&amp;rsquo;s metrics to the individual functions: when a request is slow, seeing whether it was prefill, decode, scheduler overhead, transfer or synchronisation.&lt;/p>
&lt;h4 id="cuda-in-general">CUDA in general&lt;/h4>
&lt;p>Independently of the engine, uprobes on &lt;code>libcudart.so&lt;/code> capture all the CUDA activity of the process:&lt;/p>
&lt;ul>
&lt;li>&lt;code>cudaMalloc(size)&lt;/code> → tracking of device memory allocations.&lt;/li>
&lt;li>&lt;code>cudaLaunchKernel(func, ...)&lt;/code> → spans for each kernel launch.&lt;/li>
&lt;li>&lt;code>cudaMemcpyAsync(dst, src, size, kind)&lt;/code> → host↔device transfers.&lt;/li>
&lt;li>&lt;code>cudaStreamSynchronize(stream)&lt;/code> → synchronisation points (where the host waits for the device).&lt;/li>
&lt;/ul>
&lt;p>This gives you a complete timeline of CUDA activity without needing NVIDIA Nsight Systems (which is excellent but heavy and oriented towards development, not continuous production).&lt;/p>
&lt;h3 id="hardware-counters-the-other-half">Hardware counters: the other half&lt;/h3>
&lt;p>eBPF can read performance counters from the CPU/GPU&amp;rsquo;s PMU (Performance Monitoring Unit). That includes instructions executed, cache misses, branch mispredictions and, on GPUs with support, FLOPS, SM occupancy, HBM bandwidth.&lt;/p>
&lt;p>Combining:&lt;/p>
&lt;ul>
&lt;li>uprobe: &amp;ldquo;&lt;code>llama_decode&lt;/code> ran from T1 to T2 with tokens=4&amp;rdquo;.&lt;/li>
&lt;li>perf counter: &amp;ldquo;during that window, L2 cache misses = 15,000, instructions = 2.3 million&amp;rdquo;.&lt;/li>
&lt;/ul>
&lt;p>This lets you answer: why is it slow? Is it memory-bound (many cache misses), compute-bound (all the instructions in the FPU), bandwidth-bound (a lot of data movement)? State of the art for professional profiling.&lt;/p>
&lt;h3 id="comparison-with-agentsight">Comparison with AgentSight&lt;/h3>
&lt;p>There are two eBPF products for LLMs today with different focuses:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>&lt;a href="https://blog.lo0.es/en/posts/agentsight-llm-tracing-ebpf-zero-instrumentation/">AgentSight&lt;/a>&lt;/strong> (covered in the eBPF series): observes agents that call external APIs. It hooks SSL to see the plaintext of HTTPS calls to the remote LLM, plus stdio for local MCP servers. The client view.&lt;/li>
&lt;li>&lt;strong>ProfInfer / the eBPF-on-local-inference pattern&lt;/strong>: observes the engine that runs the model locally. It hooks the engine&amp;rsquo;s internal functions (llama.cpp, vLLM) and the CUDA layer. The server (internal) view.&lt;/li>
&lt;/ul>
&lt;p>They are complementary. If your agent uses the Claude API plus your own local vLLM with Llama 3 for specific tasks, AgentSight sees the first, eBPF/ProfInfer sees the second. If everything is local, clearly the domain of the second. If everything is an external API, of the first.&lt;/p>
&lt;h3 id="use-cases-for-ebpf-in-local-inference">Use cases for eBPF in local inference&lt;/h3>
&lt;p>Three cases where it is the right tool:&lt;/p>
&lt;p>&lt;strong>Fine-grained profiling for optimisation&lt;/strong>: your vLLM takes 50ms more per token than expected. With eBPF plus hardware counters you pin down which specific kernel it is. Previously this required Nsight Systems in a development session; now it is continuous in production.&lt;/p>
&lt;p>&lt;strong>Token-level observability without modifying the engine&lt;/strong>: capturing the stream of generated tokens before returning them to the client. Useful for auditing, for drift detection over the outputs, for local tracing without going through wrapper instrumentation.&lt;/p>
&lt;p>&lt;strong>Detecting specific degradation&lt;/strong>: a new version of vLLM introduces a subtle regression in paged attention. With perf counter baselines, you detect the change even if the external metric (tokens/sec) looks the same.&lt;/p>
&lt;h2 id="part-2--statistical-analysis-of-flows-detecting-drift">Part 2 — Statistical analysis of flows: detecting drift&lt;/h2>
&lt;p>Now to the other side of the problem: the time series.&lt;/p>
&lt;h3 id="why-tracing-evals-and-guardrails-do-not-detect-drift">Why tracing, evals and guardrails do not detect drift&lt;/h3>
&lt;p>The layers we have already seen operate on individual events:&lt;/p>
&lt;ul>
&lt;li>Tracing: a trace of one conversation.&lt;/li>
&lt;li>Evals: a score for one answer.&lt;/li>
&lt;li>Guardrails: a verdict on one prompt or answer.&lt;/li>
&lt;li>MCP observability: spans of one tool invocation.&lt;/li>
&lt;/ul>
&lt;p>Each one answers a point-in-time question (&amp;ldquo;is this all right?&amp;rdquo;). None of them answers the question of evolution (&amp;ldquo;is something changing over time?&amp;rdquo;).&lt;/p>
&lt;p>The operational problem: drift is invisible in individual events. If the mean eval score drops from 0.92 to 0.85 over three weeks, no individual evaluation will raise an alarm, since they all continue to be &amp;ldquo;reasonable&amp;rdquo;. What changes is the distribution. And that only shows up when you look at many evaluations aggregated over time.&lt;/p>
&lt;h3 id="the-three-types-of-llm-drift-in-2026">The three types of LLM drift in 2026&lt;/h3>
&lt;p>&lt;a href="https://futureagi.com/blog/what-is-llm-drift-2026">FutureAGI&lt;/a> consolidates them like this, and the industry is converging on this vocabulary:&lt;/p>
&lt;p>&lt;strong>1. Prompt drift&lt;/strong>: someone updates the system prompt and the side effects break cases that used to work. Almost always intentional, but with unanticipated consequences. Detection: comparing response distributions before and after the change, monitoring eval scores per prompt version (linked in Langfuse, see the &lt;a href="https://blog.lo0.es/en/posts/agentsight-llm-tracing-ebpf-zero-instrumentation/">AgentSight&lt;/a> post where we covered prompt management).&lt;/p>
&lt;p>&lt;strong>2. Model drift&lt;/strong>: the provider (OpenAI, Anthropic) updates the model without warning. The same prompt produces answers with a slightly different tone, similar but different quality, or degradation on some subset. Detection: comparing today&amp;rsquo;s response embeddings with the baseline; monitoring rubric scores; alerting if intra-model variance grows.&lt;/p>
&lt;p>&lt;strong>3. Eval-score drift&lt;/strong>: the rolling mean of your eval metrics (faithfulness, answer relevancy, custom rubrics) trends downwards. The root cause can be any of the above or a change in the user mix. Detection: alerts on the trends of the eval series.&lt;/p>
&lt;p>To these three you can add a fourth, subtler one:&lt;/p>
&lt;p>&lt;strong>4. Persona drift / user mix shift&lt;/strong>: the population of users using the system changes. It is not that the model or the prompt got worse; it is that the new users ask different questions and the system, while still just as good at what it was good at, fails on the new material. Detection: embedding clustering of prompts, monitoring the appearance of new clusters or the growth of a minority one.&lt;/p>
&lt;h3 id="the-key-technical-concept-embedding-space-shift">The key technical concept: embedding-space shift&lt;/h3>
&lt;p>&lt;a href="https://stackpulsar.com/blog/llm-model-drift-detection/">Stack Pulsar&lt;/a> puts it plainly: in LLMs, drift is best measured in embedding space. Classic distances in token space do not capture fine semantics; in embedding space they do.&lt;/p>
&lt;p>The canonical pipeline:&lt;/p>
&lt;ol>
&lt;li>&lt;strong>Establish a baseline&lt;/strong>: during a stable period (say the first two weeks after a release), capture a large sample of prompt and response embeddings.&lt;/li>
&lt;li>&lt;strong>Continuous monitoring&lt;/strong>: every hour or every day, capture a new sample of production traffic.&lt;/li>
&lt;li>&lt;strong>Compare distributions&lt;/strong>: apply a statistical test that compares the current distribution with the baseline in embedding space.&lt;/li>
&lt;li>&lt;strong>Alert&lt;/strong>: if the divergence exceeds a threshold, fire an alert and an investigation workflow.&lt;/li>
&lt;/ol>
&lt;p>As a bonus, monitor clusters: if your baseline has 5 clusters of prompts (technical questions, general support, sales, and so on) and suddenly a sixth cluster appears that was not there, the most likely explanation is that a new user segment has arrived.&lt;/p>
&lt;h3 id="statistical-tests-ks-psi-mmd">Statistical tests: KS, PSI, MMD&lt;/h3>
&lt;p>Three tests that any drift system uses, each with its place:&lt;/p>
&lt;p>&lt;strong>Kolmogorov-Smirnov (KS)&lt;/strong>: non-parametric. It computes the maximum distance between two empirical CDFs. It returns a statistic and a p-value. Advantage: very sensitive to subtle changes, especially in the tails. Disadvantage: with large datasets it is &amp;ldquo;too sensitive&amp;rdquo;, firing alarms for changes that are real but clinically irrelevant.&lt;/p>
&lt;p>&lt;strong>Population Stability Index (PSI)&lt;/strong>: you bin the reference distribution and the current one, then sum &lt;code>(p_actual - p_ref) × log(p_actual / p_ref)&lt;/code> over the bins. The canonical interpretation: PSI &amp;lt; 0.1 stable, 0.1-0.25 mild drift, &amp;gt; 0.25 significant drift. Advantage: interpretable, threshold-based, with a tradition of use in credit scoring (Capital One, Goldman Sachs). Disadvantage: less sensitive than KS, so it misses drift in the tails.&lt;/p>
&lt;p>&lt;strong>Maximum Mean Discrepancy (MMD)&lt;/strong>: measures the divergence between two distributions by embedding each one in a Hilbert space via a kernel. It works for complex multivariate distributions (high-dimensional embeddings). Advantage: the only one that scales reasonably to embeddings of 768/1024/4096 dimensions. Disadvantage: harder to interpret.&lt;/p>
&lt;p>The recommended practice in 2026:&lt;/p>
&lt;ul>
&lt;li>PSI for simple features (prompt length, tokens, number of tools invoked).&lt;/li>
&lt;li>KS for continuous features where you want high sensitivity.&lt;/li>
&lt;li>MMD for embeddings (high-dimensional spaces).&lt;/li>
&lt;/ul>
&lt;p>&lt;a href="https://www.evidentlyai.com/blog/data-drift-detection-large-datasets">Evidently&amp;rsquo;s analysis&lt;/a> on real datasets showed that KS detects drift 6+ hours earlier than PSI in some incidents. The operational consequence: use KS for early warning, PSI for confirmation with an interpretable threshold.&lt;/p>
&lt;h3 id="tools-in-2026">Tools in 2026&lt;/h3>
&lt;p>Three products dominate the field:&lt;/p>
&lt;h4 id="evidently-ai">Evidently AI&lt;/h4>
&lt;p>&lt;a href="https://github.com/evidentlyai/evidently">Evidently&lt;/a> is open-source (Apache 2.0), Python-first. Its value:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>HTML drift reports&lt;/strong>: you generate a report comparing two datasets (reference vs current) and you get an HTML file with all the statistical tests, visualisations and conclusions. No server, no infrastructure; one shareable file.&lt;/li>
&lt;li>&lt;strong>Native LLM support&lt;/strong>: on top of tabular, it supports text. It computes embeddings and applies the appropriate tests.&lt;/li>
&lt;li>&lt;strong>100+ metrics&lt;/strong> in the suite. It covers everything from a single framework.&lt;/li>
&lt;li>&lt;strong>Integration with MLflow and kube&lt;/strong>: CI workflows with reports on every release.&lt;/li>
&lt;/ul>
&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">evidently&lt;/span> &lt;span class="kn">import&lt;/span> &lt;span class="n">Report&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">evidently.metrics&lt;/span> &lt;span class="kn">import&lt;/span> &lt;span class="n">DataDriftPreset&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">ref&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">load_baseline_dataset&lt;/span>&lt;span class="p">()&lt;/span> &lt;span class="c1"># prompts from last week&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">cur&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">load_current_dataset&lt;/span>&lt;span class="p">()&lt;/span> &lt;span class="c1"># prompts from the last hour&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">report&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">Report&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">metrics&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="n">DataDriftPreset&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="n">run&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">reference_data&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">ref&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">current_data&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">cur&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="n">save_html&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;drift_report.html&amp;#34;&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>When this functionality detects drift, it also tells you which column and which test fired.&lt;/p>
&lt;h4 id="nannyml">NannyML&lt;/h4>
&lt;p>&lt;a href="https://www.nannyml.com/">NannyML&lt;/a> has a different focus: estimating model performance when you have no ground truth. The techniques:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>CBPE (Confidence-Based Performance Estimation)&lt;/strong>: estimates accuracy using the model&amp;rsquo;s confidence in its predictions.&lt;/li>
&lt;li>&lt;strong>DLE (Direct Loss Estimation)&lt;/strong>: estimates the loss directly.&lt;/li>
&lt;/ul>
&lt;p>Useful when your LLM app has no immediate human feedback but you want to know whether its quality has dropped. Apache 2.0, Python.&lt;/p>
&lt;h4 id="whylabs">WhyLabs&lt;/h4>
&lt;p>&lt;a href="https://whylabs.ai/">WhyLabs&lt;/a> is commercial (with whylogs as the underlying OSS library), aimed at enterprise production:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Managed SaaS&lt;/strong> with SOC 2 Type 2 and HIPAA compliance.&lt;/li>
&lt;li>&lt;strong>Real-time monitoring&lt;/strong> via continuous log ingestion.&lt;/li>
&lt;li>&lt;strong>Embedding tracking&lt;/strong>: native support for embedding distributions, not just tabular features.&lt;/li>
&lt;li>&lt;strong>Token probability shifts&lt;/strong>: it monitors the probability distribution of generated tokens, not just metadata.&lt;/li>
&lt;/ul>
&lt;p>For regulated companies that do not want to operate their own drift detection platform, it is the lowest-friction option.&lt;/p>
&lt;h4 id="other-mentions">Other mentions&lt;/h4>
&lt;p>&lt;a href="https://phoenix.arize.com/">Arize Phoenix&lt;/a> (seen in the Evals post) includes drift detection as a module. &lt;a href="https://galileo.ai/">Galileo&lt;/a> has commercial products specialised in LLM monitoring. &lt;a href="https://www.fiddler.ai/">Fiddler AI&lt;/a> and &lt;a href="https://github.com/SeldonIO/alibi-detect">Alibi Detect&lt;/a> (Seldon) are more generalist alternatives that also cover LLMs.&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Tool&lt;/th>
&lt;th>Licence&lt;/th>
&lt;th>Focus&lt;/th>
&lt;th>Typical stack&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>&lt;strong>Evidently AI&lt;/strong>&lt;/td>
&lt;td>Apache 2.0&lt;/td>
&lt;td>Drift reports + LLM&lt;/td>
&lt;td>OSS Python, HTML reports&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>NannyML&lt;/strong>&lt;/td>
&lt;td>Apache 2.0&lt;/td>
&lt;td>Performance without GT&lt;/td>
&lt;td>OSS Python, batch&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>WhyLabs&lt;/strong>&lt;/td>
&lt;td>Commercial (whylogs OSS)&lt;/td>
&lt;td>Enterprise SaaS, embeddings&lt;/td>
&lt;td>Continuous logs, compliance&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Arize Phoenix&lt;/strong>&lt;/td>
&lt;td>ELv2&lt;/td>
&lt;td>Tracing + drift unified&lt;/td>
&lt;td>OSS, OTel-native&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Galileo&lt;/strong>&lt;/td>
&lt;td>Commercial&lt;/td>
&lt;td>Premium LLM monitoring&lt;/td>
&lt;td>SaaS, ML expert team&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Alibi Detect&lt;/strong>&lt;/td>
&lt;td>Apache 2.0&lt;/td>
&lt;td>General drift detection&lt;/td>
&lt;td>OSS Python, Seldon ecosystem&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Fiddler AI&lt;/strong>&lt;/td>
&lt;td>Commercial&lt;/td>
&lt;td>Explainability + monitoring&lt;/td>
&lt;td>Enterprise SaaS&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;h2 id="part-3--the-complete-stack-how-everything-fits">Part 3 — The complete stack: how everything fits&lt;/h2>
&lt;p>Let us recap the layers the two series have covered, ordered from closest to the individual request to closest to the aggregate trend:&lt;/p>
&lt;pre tabindex="0">&lt;code>Individual EVENTS Aggregated TRENDS
│ │
Tracing ──→ Evals ──→ Guardrails ──→ MCP obs ──→ Drift detection
│ │
AgentSight ──→ Tetragon ──→ Hubble ──→ eBPF on-device
│ │
(what happens) (what changes)
&lt;/code>&lt;/pre>&lt;p>Each layer answers a different question:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Layer&lt;/th>
&lt;th>Question it answers&lt;/th>
&lt;th>Granularity&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>&lt;strong>Tracing&lt;/strong> (Langfuse, AgentSight)&lt;/td>
&lt;td>What exactly did the agent do?&lt;/td>
&lt;td>One session&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Evals&lt;/strong>&lt;/td>
&lt;td>Was the answer good?&lt;/td>
&lt;td>One answer&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Guardrails&lt;/strong>&lt;/td>
&lt;td>Is this prompt/answer safe?&lt;/td>
&lt;td>One message&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>MCP observability&lt;/strong>&lt;/td>
&lt;td>Which tools did it invoke, at what cost?&lt;/td>
&lt;td>One tool call&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>eBPF on agent/network&lt;/strong> (AgentSight, Hubble)&lt;/td>
&lt;td>How did the system behave?&lt;/td>
&lt;td>Per process/connection&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>eBPF on the local engine&lt;/strong> (ProfInfer-like)&lt;/td>
&lt;td>How did the model execute?&lt;/td>
&lt;td>Per runtime function&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Drift detection&lt;/strong>&lt;/td>
&lt;td>Is something changing silently?&lt;/td>
&lt;td>Distribution&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>None of them replaces the others. Full coverage requires all seven. The practical operation:&lt;/p>
&lt;ol>
&lt;li>Layers 1-3 (tracing, evals, guardrails) are mandatory from day one. Any LLM app in production that does not have them is flying blind.&lt;/li>
&lt;li>Layer 4 (MCP) becomes mandatory when there are agents with tools, which is the majority in 2026.&lt;/li>
&lt;li>Layers 5-6 (eBPF) become valuable when the scale justifies the operating cost (&amp;gt;10 services, &amp;gt;100 inference pods).&lt;/li>
&lt;li>Layer 7 (drift) is the one that is most neglected and most expensive to ignore: it takes a day&amp;rsquo;s work to get the basic pipeline and it saves weeks of future incidents.&lt;/li>
&lt;/ol>
&lt;h2 id="the-operational-drift-pattern-in-2026">The operational drift pattern in 2026&lt;/h2>
&lt;p>The minimum recipe any serious LLM app should have:&lt;/p>
&lt;h3 id="step-1--establish-a-baseline">Step 1 — Establish a baseline&lt;/h3>
&lt;p>During a stable post-release period (2 weeks minimum), store:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Embeddings of every prompt&lt;/strong> (vector + metadata: timestamp, user_segment, tenant).&lt;/li>
&lt;li>&lt;strong>Embeddings of the responses&lt;/strong>.&lt;/li>
&lt;li>&lt;strong>Automated eval scores&lt;/strong> over a sample (e.g. 5-10% of traffic with G-Eval).&lt;/li>
&lt;li>&lt;strong>The distribution of tools invoked&lt;/strong> (which tools, with which typical arguments, at what frequency).&lt;/li>
&lt;/ul>
&lt;p>Storage: any vector store plus a relational one. Reasonable cardinality at whatever scale you have.&lt;/p>
&lt;h3 id="step-2--continuous-comparison-pipeline">Step 2 — Continuous comparison pipeline&lt;/h3>
&lt;p>Every hour (or every day depending on scale):&lt;/p>
&lt;ul>
&lt;li>Take the sample for the current period (the last hour).&lt;/li>
&lt;li>Apply the statistical tests against the baseline:
&lt;ul>
&lt;li>PSI over simple features (prompt length, tokens, num tools).&lt;/li>
&lt;li>KS over continuous features (latency, score).&lt;/li>
&lt;li>MMD over embeddings.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Generate a drift report (Evidently does it in one line of Python).&lt;/li>
&lt;/ul>
&lt;h3 id="step-3--alerts-and-investigation-workflow">Step 3 — Alerts and investigation workflow&lt;/h3>
&lt;p>Configure thresholds and routes:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>PSI &amp;gt; 0.25 over tokens consumed&lt;/strong>: moderate alert (it may be legitimate, investigate segments).&lt;/li>
&lt;li>&lt;strong>Significant MMD over prompt embeddings&lt;/strong>: high alert (a change in user mix or a coordinated attack).&lt;/li>
&lt;li>&lt;strong>Eval rubric score down &amp;gt;5% over a rolling 7d&lt;/strong>: critical alert.&lt;/li>
&lt;li>&lt;strong>A new cluster in embedding space accounting for 10%+ of traffic&lt;/strong>: a review workflow (it may be a legitimate new segment or an anomaly).&lt;/li>
&lt;/ul>
&lt;p>Every alert must lead to a drill-down dashboard with the affected segments, not to an empty Slack message. The operational rule: if someone cannot investigate the alert in &amp;lt;5 minutes, it will not get investigated.&lt;/p>
&lt;h3 id="step-4--baseline-refresh">Step 4 — Baseline refresh&lt;/h3>
&lt;p>The baseline is not static. Every N weeks, refresh the baseline by incorporating the &amp;ldquo;new stable&amp;rdquo;. If over 3 months the usage pattern has legitimately changed (more international users, new languages), the baseline must reflect it. The typical cadence: quarterly.&lt;/p>
&lt;h2 id="operational-traps">Operational traps&lt;/h2>
&lt;h3 id="a-contaminated-baseline">A contaminated baseline&lt;/h3>
&lt;p>You take the baseline from a period that already contained the problem in embryo. Result: the baseline includes the bad behaviour and the tests never fire. Solution: verify the baseline against a second independent sample (for example, the first week vs the second) before blessing it.&lt;/p>
&lt;h3 id="a-threshold-that-is-too-low">A threshold that is too low&lt;/h3>
&lt;p>PSI &amp;gt; 0.05 fires constantly. Your team learns to ignore the alerts. Calibrate thresholds against the natural noise of your system: run the system with the baseline plus successive weekly samples and measure the PSI distribution; set the threshold a couple of deviations above normal.&lt;/p>
&lt;h3 id="embeddings-that-are-not-representative">Embeddings that are not representative&lt;/h3>
&lt;p>You use OpenAI&amp;rsquo;s &lt;code>text-embedding-3-small&lt;/code> embedding model to detect drift in a system serving technical questions in Spanish about Cisco networks. Result: the embedding model does not capture the fine semantics of the domain. Solution: use embeddings finetuned for your domain, or one that is strong in multilingual and technical material.&lt;/p>
&lt;h3 id="storage-overload">Storage overload&lt;/h3>
&lt;p>Storing an embedding of every prompt in production at scale (millions of prompts/day) fills disks and increases cost. Stratified sampling: keep 5-10% of traffic, but make sure minority segments are over-represented so you do not lose them.&lt;/p>
&lt;h3 id="confusing-drift-with-the-system-works">Confusing drift with &amp;ldquo;the system works&amp;rdquo;&lt;/h3>
&lt;p>Sometimes drift is good drift: new users discover the agent knows how to do X, and suddenly 30% of traffic is for X. The distribution changed because the product found a new use. Before pulling the alarm, check whether the change is desirable.&lt;/p>
&lt;h3 id="privacy-in-embedding-storage">Privacy in embedding storage&lt;/h3>
&lt;p>Embeddings can be partially inverted back to their original text with embedding inversion techniques. If the prompts contain PII, storing embeddings for months for drift detection is a leak vector. Encrypt at rest and rotate regularly, or work with aggregated/averaged embeddings.&lt;/p>
&lt;h3 id="ebpf-in-production-without-profile-guardrails">eBPF in production without profile guardrails&lt;/h3>
&lt;p>Attaching uprobes to hot-path functions such as &lt;code>llama_decode&lt;/code> can hit throughput if it is not done carefully. Always test in staging and monitor the overhead. ProfInfer reports &amp;lt;4%; what you measure may vary with your binary and kernel.&lt;/p>
&lt;h2 id="closing-the-two-series">Closing the two series&lt;/h2>
&lt;p>This week we have written 12 articles that walk the modern LLM inference stack in production from top to bottom:&lt;/p>
&lt;p>&lt;strong>LLM inference series (4 articles)&lt;/strong>:&lt;/p>
&lt;ul>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/kv-cache-working-memory-llm-inference/">KV cache: the working memory that holds up LLM inference&lt;/a> — fundamentals.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/vllm-kubernetes-llm-inference-that-scales/">vLLM on Kubernetes&lt;/a> — the engine.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/pagedattention-vllm-block-manager/">PagedAttention deep dive&lt;/a> — how it works inside.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/llm-inference-operators-kubernetes/">LLM Operators on Kubernetes&lt;/a> — orchestration.&lt;/li>
&lt;/ul>
&lt;p>&lt;strong>eBPF series (4 articles)&lt;/strong>:&lt;/p>
&lt;ul>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/ebpf-zero-to-cilium-kernel-tcp-ip-bypass/">eBPF from zero to Cilium&lt;/a> — the substrate.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/tetragon-cilium-security-syscalls-kernel/">Tetragon&lt;/a> — runtime security.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/hubble-ebpf-network-observability-ai-agents/">Hubble&lt;/a> — network observability.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/agentsight-llm-tracing-ebpf-zero-instrumentation/">AgentSight&lt;/a> — agent observability.&lt;/li>
&lt;/ul>
&lt;p>&lt;strong>Post-tracing series (4 articles)&lt;/strong>:&lt;/p>
&lt;ul>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/evals-llm-layer-after-tracing/">Evals&lt;/a> — reactive quality.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/llm-guardrails-safety-four-lines-of-defence/">Guardrails&lt;/a> — preventive safety.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/mcp-deep-observability-opentelemetry/">MCP observability&lt;/a> — the tool protocol.&lt;/li>
&lt;li>This one — drift detection and eBPF in local inference.&lt;/li>
&lt;/ul>
&lt;p>If you read the twelve in order you have a reasonably complete map of what it takes to operate AI agents in serious production in 2026, with enough detail not to crash into the usual problems in the first month. And, above all, with the mindset that LLM observability is a stack, not a product: each layer solves a problem, none of them solves all of them, and the combination is what separates an operable system from one that holds up until the first incident.&lt;/p>
&lt;h2 id="what-is-left-for-future-series">What is left for future series&lt;/h2>
&lt;ul>
&lt;li>&lt;strong>MLOps specific to LLMs&lt;/strong>: continuous fine-tuning, RAG over data lakes, agent training.&lt;/li>
&lt;li>&lt;strong>Constitutional AI and alignment at runtime&lt;/strong>: how the model self-regulates with internal guardrails.&lt;/li>
&lt;li>&lt;strong>GPU networking&lt;/strong>: InfiniBand, NCCL, GPUDirect, the angle we left untouched.&lt;/li>
&lt;li>&lt;strong>Edge inference&lt;/strong>: llama.cpp on phones, MLX on macOS, Snapdragon NPU.&lt;/li>
&lt;li>&lt;strong>Theoretical inference scheduling&lt;/strong>: CFS-like algorithms applied to multi-tenant LLM serving.&lt;/li>
&lt;/ul>
&lt;p>We will cover them in time. That is it for now, and thanks for reading these twelve posts. If they gave you something, share them with a colleague.&lt;/p>
&lt;h2 id="references">References&lt;/h2>
&lt;p>eBPF in local inference:&lt;/p>
&lt;ul>
&lt;li>&lt;a href="https://arxiv.org/abs/2601.20755">ProfInfer: An eBPF-based Fine-Grained LLM Inference Profiler (arxiv 2601.20755)&lt;/a> — the 2026 reference paper.&lt;/li>
&lt;li>&lt;a href="https://www.glukhov.org/observability/monitoring-llm-inference-prometheus-grafana/">Monitor LLM Inference in Production 2026 (Glukhov)&lt;/a> — Prometheus + Grafana for vLLM/TGI/llama.cpp.&lt;/li>
&lt;li>&lt;a href="https://www.armosec.io/blog/observability-for-ai-inference-servers/">AI Inference Server Observability in Kubernetes (ARMO)&lt;/a> — the four signals MLOps tools do not capture.&lt;/li>
&lt;li>&lt;a href="https://developers.redhat.com/articles/2025/09/30/vllm-or-llamacpp-choosing-right-llm-inference-engine-your-use-case">vLLM vs llama.cpp: Choosing the right engine (Red Hat)&lt;/a>.&lt;/li>
&lt;/ul>
&lt;p>Drift detection concepts:&lt;/p>
&lt;ul>
&lt;li>&lt;a href="https://futureagi.com/blog/what-is-llm-drift-2026">What is LLM Drift? Types, Detection, Mitigation 2026 (FutureAGI)&lt;/a>.&lt;/li>
&lt;li>&lt;a href="https://stackpulsar.com/blog/llm-model-drift-detection/">LLM Model Drift Detection 2026 (Stack Pulsar)&lt;/a>.&lt;/li>
&lt;li>&lt;a href="https://galileo.ai/blog/best-llm-output-drift-monitoring-platforms">9 Best LLM Drift Monitoring Platforms in 2026 (Galileo)&lt;/a>.&lt;/li>
&lt;li>&lt;a href="https://arxiv.org/abs/2404.18673">Open-Source Drift Detection Tools in Action (arxiv 2404.18673)&lt;/a>.&lt;/li>
&lt;li>&lt;a href="https://arxiv.org/abs/2309.10000">Detecting covariate drift in text data using document embeddings (arxiv 2309.10000)&lt;/a>.&lt;/li>
&lt;li>&lt;a href="https://futureagi.com/blog/best-ai-drift-detection-tools-2026">Best AI Drift Detection Tools in 2026 (FutureAGI)&lt;/a>.&lt;/li>
&lt;/ul>
&lt;p>Tools:&lt;/p>
&lt;ul>
&lt;li>&lt;a href="https://github.com/evidentlyai/evidently">Evidently AI (GitHub)&lt;/a> — open-source.&lt;/li>
&lt;li>&lt;a href="https://www.evidentlyai.com/">Evidently — official site&lt;/a>.&lt;/li>
&lt;li>&lt;a href="https://www.nannyml.com/">NannyML&lt;/a> — performance without ground truth.&lt;/li>
&lt;li>&lt;a href="https://whylabs.ai/">WhyLabs&lt;/a> — managed observability.&lt;/li>
&lt;li>&lt;a href="https://github.com/SeldonIO/alibi-detect">Alibi Detect (Seldon)&lt;/a> — general drift detection.&lt;/li>
&lt;li>&lt;a href="https://phoenix.arize.com/">Arize Phoenix&lt;/a> — drift integrated with tracing.&lt;/li>
&lt;/ul>
&lt;p>Statistical tests:&lt;/p>
&lt;ul>
&lt;li>&lt;a href="https://mlpipeline-cloud.com/blog/data-drift-detection-psi-ks">Data drift detection: PSI vs Kolmogorov–Smirnov (MLPipeline)&lt;/a> — a practical comparison.&lt;/li>
&lt;li>&lt;a href="https://brandonwie.dev/posts/psi-model-drift-detection">Population Stability Index for Model Drift Detection&lt;/a>.&lt;/li>
&lt;li>&lt;a href="https://www.evidentlyai.com/blog/data-drift-detection-large-datasets">Which test is the best? 5 methods to detect data drift (Evidently)&lt;/a> — the 6 hours of advantage for KS.&lt;/li>
&lt;/ul>
&lt;p>Cross-references (the three complete series):&lt;/p>
&lt;ul>
&lt;li>LLM inference series: &lt;a href="https://blog.lo0.es/en/posts/kv-cache-working-memory-llm-inference/">KV cache&lt;/a>, &lt;a href="https://blog.lo0.es/en/posts/vllm-kubernetes-llm-inference-that-scales/">vLLM on K8s&lt;/a>, &lt;a href="https://blog.lo0.es/en/posts/pagedattention-vllm-block-manager/">PagedAttention&lt;/a>, &lt;a href="https://blog.lo0.es/en/posts/llm-inference-operators-kubernetes/">LLM K8s Operators&lt;/a>.&lt;/li>
&lt;li>eBPF series: &lt;a href="https://blog.lo0.es/en/posts/ebpf-zero-to-cilium-kernel-tcp-ip-bypass/">eBPF from zero to Cilium&lt;/a>, &lt;a href="https://blog.lo0.es/en/posts/tetragon-cilium-security-syscalls-kernel/">Tetragon&lt;/a>, &lt;a href="https://blog.lo0.es/en/posts/hubble-ebpf-network-observability-ai-agents/">Hubble&lt;/a>, &lt;a href="https://blog.lo0.es/en/posts/agentsight-llm-tracing-ebpf-zero-instrumentation/">AgentSight&lt;/a>.&lt;/li>
&lt;li>Post-tracing series: &lt;a href="https://blog.lo0.es/en/posts/evals-llm-layer-after-tracing/">Evals&lt;/a>, &lt;a href="https://blog.lo0.es/en/posts/llm-guardrails-safety-four-lines-of-defence/">Guardrails&lt;/a>, &lt;a href="https://blog.lo0.es/en/posts/mcp-deep-observability-opentelemetry/">MCP observability&lt;/a>.&lt;/li>
&lt;/ul></description></item><item><title>MCP from the inside and its deep observability: the LSP of AI agents and how to see everything with OpenTelemetry</title><link>https://blog.lo0.es/en/posts/mcp-deep-observability-opentelemetry/</link><pubDate>Wed, 20 May 2026 06:00:00 +0200</pubDate><guid>https://blog.lo0.es/en/posts/mcp-deep-observability-opentelemetry/</guid><description>&lt;h2 id="tldr">TL;DR&lt;/h2>
&lt;p>&lt;a href="https://modelcontextprotocol.io/">Model Context Protocol (MCP)&lt;/a> is the standard Anthropic published at the end of 2024 and which by 2026 has become &lt;strong>the dominant protocol for connecting AI agents to external tools and data&lt;/strong>. Its value, the reason the whole industry adopted it in under 18 months, is that it &lt;strong>solves a combinatorial problem&lt;/strong>: before MCP, integrating M AI apps with N tools required M×N ad-hoc integrations; with MCP, M + N. It is the same move the &lt;a href="https://microsoft.github.io/language-server-protocol/">Language Server Protocol&lt;/a> made in 2016 for code editors. The architecture is three well-defined roles, &lt;strong>Host&lt;/strong> (the AI app), &lt;strong>Client&lt;/strong> (the connection, one per server) and &lt;strong>Server&lt;/strong> (the piece that exposes capabilities); the primitives are six, three on the server side (Tools, Resources, Prompts) and three on the client side (Sampling, Roots, Elicitation); the protocol is JSON-RPC over two transports, stdio for local processes and Streamable HTTP for remote. The operational challenge appears when there are 10-20 MCP servers running simultaneously, each with several tools, connected to an agent that chains multistep calls: &lt;strong>observing what happens, where things fail, how much each tool costs, which tenant invokes what&lt;/strong> becomes critical. The ecosystem&amp;rsquo;s answer in 2026: the new &lt;strong>OpenTelemetry GenAI semantic conventions for MCP&lt;/strong> (already stable), trace context propagation via &lt;code>params._meta&lt;/code> (because JSON-RPC does not bring it natively), FastMCP with built-in OTel instrumentation, MCP Gateways as a centralised layer (Traefik Hub, MintMCP, OpenObserve), and MCP Inspector for interactive debugging. This article walks the architecture from the outside in, puts each concept in its exact place, and goes down to the detail of observability: traces, RED metrics, real use cases and pitfalls.&lt;/p>
&lt;blockquote>
&lt;p>This is the &lt;strong>third post in the post-tracing series&lt;/strong>. Previous posts: &lt;a href="https://blog.lo0.es/en/posts/evals-llm-layer-after-tracing/">Evals&lt;/a> and &lt;a href="https://blog.lo0.es/en/posts/llm-guardrails-safety-four-lines-of-defence/">Guardrails&lt;/a>. Here we go down to the protocol that connects agents to tools, and how to see it in production.&lt;/p>
&lt;/blockquote>
&lt;h2 id="the-master-analogy-in-three-versions">The master analogy (in three versions)&lt;/h2>
&lt;p>MCP is a communication protocol. Like any protocol, it is best understood with the right analogy. I am going to give you three, because each one lights up a different facet and the combination leaves you understanding it better than any technical definition.&lt;/p>
&lt;h3 id="version-1--the-usb-c-of-ai-apps-the-official-one">Version 1 — The USB-C of AI apps (the official one)&lt;/h3>
&lt;p>This is the analogy Anthropic adopted when presenting it. Before USB-C, every electronic device had its own connector. Your phone had microUSB or Lightning, your laptop a proprietary power port, your headphones a 3.5mm jack, your external disk USB-A at one end and mini-USB at the other. The result: three boxes full of specific cables that got lost, none of them good for two things, and buying a new device meant buying new accessories.&lt;/p>
&lt;p>USB-C changed that. A single physical connector that many protocols cross: data (USB 3, USB 4, Thunderbolt), video (DisplayPort), power (Power Delivery), audio. You connect anything to anything and it works; the protocols negotiate above.&lt;/p>
&lt;p>MCP plays the same role for AI apps. Before MCP, every application that wanted to integrate tools with an LLM, Claude Desktop, Cursor, Continue, in-house custom agents, invented its own way of doing it. Every tool vendor had to write N different integrations, one per app. The result: massive fragmentation, a lot of duplicated code, integrations that broke when an app changed its internal API.&lt;/p>
&lt;p>With MCP, the connector is one: any app that speaks MCP can use any MCP tool. Just as your USB-C talks to printers, monitors and disks without the printer &amp;ldquo;knowing&amp;rdquo; whether the cable is connected to a Mac or to a Linux box.&lt;/p>
&lt;h3 id="version-2--the-lsp-of-code-editors-the-most-technically-precise">Version 2 — The LSP of code editors (the most technically precise)&lt;/h3>
&lt;p>This is my favourite because the analogy is structurally identical, not just metaphorical.&lt;/p>
&lt;p>Until 2016, if you wanted your code editor to support a new language, Rust, Go, TypeScript, somebody had to write a specific plugin for your particular editor. VSCode had its Rust plugin, IntelliJ a different one, Vim another, Emacs another. Every decent feature (go-to-definition, autocompletion, refactoring) was an implementation duplicated N times. &lt;strong>M editors × N languages = M·N integrations&lt;/strong>.&lt;/p>
&lt;p>In 2016 Microsoft proposed the &lt;strong>Language Server Protocol (LSP)&lt;/strong>: each language implements a single &amp;ldquo;language server&amp;rdquo; (a process that understands that language); each editor implements a single LSP client; when you work with Rust code in VSCode, VSCode launches rust-analyzer as a subprocess and talks LSP to it over stdio. Any LSP editor + any LSP server = it works. &lt;strong>M + N&lt;/strong>.&lt;/p>
&lt;p>MCP is literally this pattern, transferred from &amp;ldquo;editor + language server&amp;rdquo; to &amp;ldquo;AI app + tool provider&amp;rdquo;. And it shares even the technical detail: both pass &lt;strong>JSON-RPC over stdio&lt;/strong> (among other transports). When Anthropic designed MCP, they looked at LSP. Anyone coming from the world of editors and IDEs will find MCP familiar.&lt;/p>
&lt;h3 id="version-3--the-operating-system-driver-the-operational-one">Version 3 — The operating system driver (the operational one)&lt;/h3>
&lt;p>Finally, an analogy that helps you understand what a specific MCP server does.&lt;/p>
&lt;p>An operating system does not know directly how to talk to your particular HP LaserJet printer. What it knows is a generic interface: &amp;ldquo;print document&amp;rdquo;, &amp;ldquo;query status&amp;rdquo;, &amp;ldquo;cancel job&amp;rdquo;. The printer driver is the piece that translates that generic interface into the proprietary commands of your specific printer.&lt;/p>
&lt;p>An MCP server does exactly the same:&lt;/p>
&lt;ul>
&lt;li>Your AI agent knows a generic interface: invoke a tool with a defined schema, read a resource by URI, ask for a prompt template by name.&lt;/li>
&lt;li>The &lt;strong>MCP server&lt;/strong> is the driver: it translates those generic operations into the concrete APIs of the underlying system, your PostgreSQL database, your filesystem, your GitHub API, your Stripe.&lt;/li>
&lt;/ul>
&lt;p>This leaves the AI agent free from knowing how it authenticates with GitHub, what exact SQL PostgreSQL uses, what endpoints Stripe has. It speaks MCP; the server takes care of the details.&lt;/p>
&lt;p>With the three analogies combined: &lt;strong>MCP is the layer between the LLM and the world, a standard USB-C implemented like LSP in JSON-RPC, with each server acting as a driver for one concrete underlying system&lt;/strong>.&lt;/p>
&lt;h2 id="what-concrete-problem-mcp-solves">What concrete problem MCP solves&lt;/h2>
&lt;p>Before going down to the architecture, it is worth pinning down the specific problem MCP solves, because without that many design decisions look arbitrary.&lt;/p>
&lt;p>The problem is &lt;strong>the quadratic cost of integrations&lt;/strong>.&lt;/p>
&lt;p>Imagine you have M applications that use LLMs (Claude Desktop, Cursor, Continue, ChatGPT Desktop, your own custom agent, and so on) and N external tools those LLMs could use (filesystem, GitHub, Slack, PostgreSQL, Jira, Notion, and so on). Without a standard:&lt;/p>
&lt;ul>
&lt;li>Each (application, tool) pair requires a specific integration.&lt;/li>
&lt;li>Every time the application changes its internal API, N integrations have to be updated.&lt;/li>
&lt;li>Every time the tool changes its API, M have to be updated.&lt;/li>
&lt;li>For your new tool to be adopted, you have to write M integrations.&lt;/li>
&lt;li>For your new application to support the ecosystem, you have to write N.&lt;/li>
&lt;/ul>
&lt;p>The real result in 2023-2024: massive fragmentation. OpenAI&amp;rsquo;s function calling was not compatible with Anthropic&amp;rsquo;s tool use; every framework (LangChain, LlamaIndex, dspy) had its own wrapper; Claude Desktop plugins did not work in Cursor; and so on.&lt;/p>
&lt;p>MCP breaks the quadratic. Each application implements the protocol once; each tool implements the protocol once; any pair works. M + N.&lt;/p>
&lt;p>It is exactly what happened with USB-C, with LSP, with SQL (before there were proprietary APIs per database), with POSIX (before there were proprietary APIs per operating system). The pattern repeats because it always solves the same kind of problem.&lt;/p>
&lt;h2 id="the-architecture-three-roles-clearly-placed">The architecture: three roles, clearly placed&lt;/h2>
&lt;p>We are going to pin down where each thing lives, because mixing up the roles is the number one source of confusion in MCP.&lt;/p>
&lt;div class="diagram" style="max-width:720px;margin:1.5rem auto;">
&lt;svg viewBox="0 0 720 360" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="MCP architecture: Host, Client, Server">
&lt;style>.title{font:600 13px sans-serif;fill:#222}.lbl{font:600 12px sans-serif;fill:#222}.sm{font:11px sans-serif;fill:#555}.box{stroke:#444;stroke-width:1.4}.host{fill:#ffe9d6}.llm{fill:#ffd6d6}.client{fill:#d6eaff}.server{fill:#d9f5d6}.sys{fill:#eee;stroke-dasharray:4 2}.arr{stroke:#666;stroke-width:1.4;fill:none;marker-end:url(#mh)}.bidi{stroke:#888;stroke-width:1.2;fill:none}&lt;/style>
&lt;defs>&lt;marker id="mh" 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="360" y="20" text-anchor="middle" class="title">MCP architecture: where each piece lives&lt;/text>
&lt;rect x="30" y="40" width="280" height="280" rx="8" class="box host"/>
&lt;text x="170" y="60" text-anchor="middle" class="lbl">HOST&lt;/text>
&lt;text x="170" y="76" text-anchor="middle" class="sm">AI app: Claude Desktop, Cursor, own agent&lt;/text>
&lt;rect x="55" y="95" width="230" height="50" rx="6" class="box llm"/>
&lt;text x="170" y="116" text-anchor="middle" class="lbl">LLM (reasoning engine)&lt;/text>
&lt;text x="170" y="132" text-anchor="middle" class="sm">decides which tools to call, what to read&lt;/text>
&lt;rect x="55" y="160" width="100" height="36" rx="6" class="box client"/>
&lt;text x="105" y="183" text-anchor="middle" class="lbl">Client 1&lt;/text>
&lt;rect x="160" y="160" width="120" height="36" rx="6" class="box client"/>
&lt;text x="220" y="183" text-anchor="middle" class="lbl">Client 2&lt;/text>
&lt;rect x="55" y="210" width="100" height="36" rx="6" class="box client"/>
&lt;text x="105" y="233" text-anchor="middle" class="lbl">Client 3&lt;/text>
&lt;rect x="160" y="210" width="120" height="36" rx="6" class="box client"/>
&lt;text x="220" y="233" text-anchor="middle" class="lbl">Client N&lt;/text>
&lt;text x="170" y="275" text-anchor="middle" class="sm">one MCP client per connected server&lt;/text>
&lt;text x="170" y="295" text-anchor="middle" class="sm">each client is a 1:1 connection&lt;/text>
&lt;rect x="380" y="60" width="200" height="70" rx="6" class="box server"/>
&lt;text x="480" y="82" text-anchor="middle" class="lbl">Server: filesystem-mcp&lt;/text>
&lt;text x="480" y="100" text-anchor="middle" class="sm">stdio (local process)&lt;/text>
&lt;text x="480" y="116" text-anchor="middle" class="sm">tools: read, write, list, search&lt;/text>
&lt;rect x="380" y="140" width="200" height="70" rx="6" class="box server"/>
&lt;text x="480" y="162" text-anchor="middle" class="lbl">Server: github-mcp&lt;/text>
&lt;text x="480" y="180" text-anchor="middle" class="sm">Streamable HTTP (remote)&lt;/text>
&lt;text x="480" y="196" text-anchor="middle" class="sm">tools: create_issue, get_pr, ...&lt;/text>
&lt;rect x="380" y="220" width="200" height="70" rx="6" class="box server"/>
&lt;text x="480" y="242" text-anchor="middle" class="lbl">Server: postgres-mcp&lt;/text>
&lt;text x="480" y="260" text-anchor="middle" class="sm">stdio (local process)&lt;/text>
&lt;text x="480" y="276" text-anchor="middle" class="sm">tools: query, schema; resources: tables&lt;/text>
&lt;rect x="610" y="60" width="80" height="70" rx="6" class="box sys"/>
&lt;text x="650" y="92" text-anchor="middle" class="sm">local FS&lt;/text>
&lt;text x="650" y="108" text-anchor="middle" class="sm">↕&lt;/text>
&lt;rect x="610" y="140" width="80" height="70" rx="6" class="box sys"/>
&lt;text x="650" y="172" text-anchor="middle" class="sm">GitHub API&lt;/text>
&lt;text x="650" y="188" text-anchor="middle" class="sm">↕&lt;/text>
&lt;rect x="610" y="220" width="80" height="70" rx="6" class="box sys"/>
&lt;text x="650" y="252" text-anchor="middle" class="sm">PostgreSQL&lt;/text>
&lt;text x="650" y="268" text-anchor="middle" class="sm">↕&lt;/text>
&lt;path class="bidi" d="M155,178 L380,95"/>
&lt;path class="bidi" d="M280,178 L380,175"/>
&lt;path class="bidi" d="M155,228 L380,255"/>
&lt;path class="bidi" d="M580,95 L610,95"/>
&lt;path class="bidi" d="M580,175 L610,175"/>
&lt;path class="bidi" d="M580,255 L610,255"/>
&lt;text x="170" y="340" text-anchor="middle" class="sm">the clients inside the host speak MCP to the servers; the servers translate to the system&lt;/text>
&lt;/svg>
&lt;/div>
&lt;p>Three roles. Let us pin down what each one does and where it physically lives.&lt;/p>
&lt;h3 id="host-the-ai-application">Host: the AI application&lt;/h3>
&lt;p>The &lt;strong>Host&lt;/strong> is the application the user opens. Claude Desktop, Cursor, Continue, ChatGPT Desktop, a custom agent your team builds, a VSCode extension. What the user perceives as &amp;ldquo;the product&amp;rdquo;.&lt;/p>
&lt;p>The Host is responsible for:&lt;/p>
&lt;ul>
&lt;li>Deciding which MCP servers to connect (configured by the user in a file or via the UI).&lt;/li>
&lt;li>Launching or connecting to each MCP server.&lt;/li>
&lt;li>Creating one MCP Client per server (it is 1:1, they are not shared).&lt;/li>
&lt;li>Embedding the &lt;strong>LLM&lt;/strong> (or calling it via API) that takes the decisions about which tools to use.&lt;/li>
&lt;li>Mediating the user&amp;rsquo;s authorisation for sensitive actions (showing the human &amp;ldquo;the agent wants to run tool X, do you allow it?&amp;rdquo;).&lt;/li>
&lt;/ul>
&lt;p>Important: &lt;strong>the LLM lives inside the Host&lt;/strong>, not in the servers. The servers are dumb; they execute operations when asked. The reasoning (&amp;ldquo;should I call this tool now?&amp;rdquo;) lives in the host&amp;rsquo;s LLM.&lt;/p>
&lt;h3 id="client-the-connection-one-per-server">Client: the connection, one per server&lt;/h3>
&lt;p>An &lt;strong>MCP Client&lt;/strong> is a specific connection between the Host and a Server. If your Host has 5 MCP servers configured, it has 5 clients, not one shared. Each client:&lt;/p>
&lt;ul>
&lt;li>Maintains its socket or stdio pipe with the server.&lt;/li>
&lt;li>Negotiates capabilities in the initial handshake (which protocol version, which primitives both support).&lt;/li>
&lt;li>Serialises JSON-RPC requests to the server and deserialises responses.&lt;/li>
&lt;li>Is the point where the Host invokes operations on the server.&lt;/li>
&lt;/ul>
&lt;p>The 1:1 client-server separation matters because it lets each server have its own session state, its specific permissions and its independent authenticated context. There is no multiplexing in the client.&lt;/p>
&lt;h3 id="server-the-piece-that-exposes-capabilities">Server: the piece that exposes capabilities&lt;/h3>
&lt;p>The &lt;strong>MCP Server&lt;/strong> is the piece that implements the tool-provider side of the protocol. It receives JSON-RPC from the client, processes it, executes the action against the underlying system and returns a response.&lt;/p>
&lt;p>There are two flavours physically:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Local server&lt;/strong>: it starts as a subprocess of the Host and communicates over stdio. Its lifecycle is the Host&amp;rsquo;s (when you close Claude Desktop, the local servers die). Typical model: your Host launches &lt;code>node filesystem-mcp-server.js&lt;/code> as a child.&lt;/li>
&lt;li>&lt;strong>Remote server&lt;/strong>: it runs as an independent service, reachable over HTTP. Multi-tenant, authenticated, scalable. Typical model: a company publishes &lt;code>https://mcp.acme.com/v1&lt;/code> and many hosts connect to it.&lt;/li>
&lt;/ul>
&lt;p>This difference has enormous consequences for observability (we will come back to it shortly).&lt;/p>
&lt;h3 id="summary-of-where-each-thing-lives">Summary of where each thing lives&lt;/h3>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Component&lt;/th>
&lt;th>Lives in&lt;/th>
&lt;th>How many there are&lt;/th>
&lt;th>Speaks what with whom&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Host&lt;/td>
&lt;td>User&amp;rsquo;s machine&lt;/td>
&lt;td>1 (the open app)&lt;/td>
&lt;td>UI with the user; launches clients&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>LLM&lt;/td>
&lt;td>Embedded in the Host (or cloud API)&lt;/td>
&lt;td>1 (the main one)&lt;/td>
&lt;td>Reasons; asks for tools&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Client&lt;/td>
&lt;td>Host&lt;/td>
&lt;td>1 per server&lt;/td>
&lt;td>JSON-RPC with its server&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Local server&lt;/td>
&lt;td>Subprocess of the Host&lt;/td>
&lt;td>1 per local integration&lt;/td>
&lt;td>stdio with its client&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Remote server&lt;/td>
&lt;td>External service&lt;/td>
&lt;td>1 per service&lt;/td>
&lt;td>HTTP/SSE with its clients&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Underlying system&lt;/td>
&lt;td>External&lt;/td>
&lt;td>Depends&lt;/td>
&lt;td>API/DB/FS, not MCP&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>If you get confused in a discussion, come back to this table. The number one source of errors in MCP is saying &amp;ldquo;the server&amp;rdquo; when you mean &amp;ldquo;the host&amp;rdquo;.&lt;/p>
&lt;h2 id="the-two-layers-of-the-protocol">The two layers of the protocol&lt;/h2>
&lt;p>MCP separates the &lt;strong>data layer&lt;/strong> and the &lt;strong>transport layer&lt;/strong>. This separation is what lets the protocol work over local stdio and over remote HTTP without changing anything in the primitives.&lt;/p>
&lt;h3 id="data-layer-json-rpc-with-mcp-extensions">Data Layer: JSON-RPC with MCP extensions&lt;/h3>
&lt;p>The data layer defines the vocabulary of the messages. It is &lt;strong>JSON-RPC 2.0&lt;/strong>. Each message is a JSON with &lt;code>jsonrpc: &amp;quot;2.0&amp;quot;&lt;/code>, a &lt;code>method&lt;/code> (eg &lt;code>tools/call&lt;/code>, &lt;code>resources/read&lt;/code>), &lt;code>params&lt;/code>, and an &lt;code>id&lt;/code> to correlate request with response.&lt;/p>
&lt;p>On top of JSON-RPC, MCP adds:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Lifecycle&lt;/strong>: the initial handshake (&lt;code>initialize&lt;/code>, &lt;code>initialized&lt;/code>) that negotiates capabilities.&lt;/li>
&lt;li>&lt;strong>The primitives&lt;/strong> (next section): &lt;code>tools/*&lt;/code>, &lt;code>resources/*&lt;/code>, &lt;code>prompts/*&lt;/code>, &lt;code>sampling/*&lt;/code> and so on.&lt;/li>
&lt;li>&lt;strong>Notifications&lt;/strong>: messages without a response (eg &lt;code>notifications/cancelled&lt;/code> to abort a tool in progress).&lt;/li>
&lt;li>&lt;strong>Meta-information&lt;/strong>: the &lt;code>params._meta&lt;/code> field carries cross-cutting metadata by convention (trace context, request IDs).&lt;/li>
&lt;/ul>
&lt;h3 id="transport-layer-how-the-messages-move">Transport Layer: how the messages move&lt;/h3>
&lt;p>The transport layer defines how the JSON-RPC messages travel. Two official transports:&lt;/p>
&lt;p>&lt;strong>stdio&lt;/strong>: the client launches the server as a subprocess and they communicate over its stdin/stdout/stderr with JSON-RPC. One message per line, separated by a newline. No network, no TLS handshake, no auth (trust is inherited from the operating system itself: if you launch the subprocess, you trust it). Minimal latency (~100 μs round-trip), maximum bandwidth (memcpy, not a socket).&lt;/p>
&lt;p>Use case: local servers that live on the same machine as the host. Most of the MCP servers you see in public directories are stdio.&lt;/p>
&lt;p>&lt;strong>Streamable HTTP&lt;/strong>: the client sends a POST to an HTTP endpoint on the server; the server responds with JSON, optionally opening a Server-Sent Events stream to send asynchronous notifications or long responses. Auth by bearer token, API key or custom headers.&lt;/p>
&lt;p>Introduced in the November 2025 spec, it replaces the pure SSE transport of earlier versions, which had bidirectionality limitations. Use case: remote servers serving many simultaneous clients, with authentication and multi-tenancy.&lt;/p>
&lt;p>Important: the primitives are the same on both transports. A &lt;code>tools/call&lt;/code> is identical over stdio and over HTTP. The transport is accidental, not fundamental.&lt;/p>
&lt;h2 id="the-six-primitives-placed-in-the-architecture">The six primitives: placed in the architecture&lt;/h2>
&lt;p>Here is the meat. There are six primitives in MCP. They are often confused because several look like they do similar things. The key classification: &lt;strong>three live on the server side&lt;/strong> (the server exposes, the client consumes) and &lt;strong>three on the client side&lt;/strong> (the client exposes, the server consumes).&lt;/p>
&lt;h3 id="server-side-what-the-server-gives-the-host">Server-side: what the server gives the host&lt;/h3>
&lt;p>&lt;strong>Tools&lt;/strong> are actions the server exposes. Each tool has a schema (typed parameters, description) and an implementation. When the host&amp;rsquo;s LLM decides to invoke a tool, the client sends &lt;code>tools/call&lt;/code> to the server, which executes it and returns a result.&lt;/p>
&lt;ul>
&lt;li>Example: the &lt;code>github-mcp&lt;/code> server exposes &lt;code>create_issue(repo, title, body)&lt;/code>. The host&amp;rsquo;s LLM decides &amp;ldquo;I am going to create an issue&amp;rdquo;, calls this tool, github-mcp talks to the GitHub API, returns the issue ID to the LLM.&lt;/li>
&lt;li>Architectural place: &lt;strong>the server exposes them, the LLM consumes them&lt;/strong>.&lt;/li>
&lt;/ul>
&lt;p>&lt;strong>Resources&lt;/strong> are contextual data the server exposes, addressable by URI. They are not actions; they are content reads. A resource has a URI (&lt;code>file:///path/to/doc.md&lt;/code>, &lt;code>postgres://table/users&lt;/code>), metadata and an endpoint to read content.&lt;/p>
&lt;ul>
&lt;li>Example: the &lt;code>filesystem-mcp&lt;/code> server exposes the files in the authorised directories as resources. The LLM asks for &lt;code>resources/read&lt;/code> with the URI &lt;code>file:///docs/api.md&lt;/code> and gets the text.&lt;/li>
&lt;li>Architectural place: &lt;strong>the server exposes them, the host reads them (and optionally passes them to the LLM as context)&lt;/strong>.&lt;/li>
&lt;/ul>
&lt;p>Key difference between Tools and Resources: &lt;strong>Tools are verbs&lt;/strong> (they execute, they modify state, they have side effects); &lt;strong>Resources are nouns&lt;/strong> (they exist, they are read, they are idempotent). If you have something that is &amp;ldquo;search for text in files&amp;rdquo; → probably a Tool (an action). If it is &amp;ldquo;this specific file&amp;rdquo; → a Resource. The distinction matters for auditing and permissions: tools require more control.&lt;/p>
&lt;p>&lt;strong>Prompts&lt;/strong> are parameterised prompt templates the server exposes. The user or the host can invoke them to inject a conversational pattern into the model.&lt;/p>
&lt;ul>
&lt;li>Example: a &lt;code>code-review-mcp&lt;/code> server exposes a prompt &lt;code>review_diff(diff_text, style=&amp;quot;strict&amp;quot;)&lt;/code> that returns a complete, well-written prompt asking the LLM to review code.&lt;/li>
&lt;li>Architectural place: &lt;strong>the server exposes them, the user or the host invokes them, the LLM receives them as input&lt;/strong>.&lt;/li>
&lt;/ul>
&lt;p>Prompts are the least used of the three primitives; many servers do not even implement them. But they let a team publish good prompts as a reusable library, separate from the agent.&lt;/p>
&lt;h3 id="client-side-what-the-host-gives-the-server">Client-side: what the host gives the server&lt;/h3>
&lt;p>This is where MCP differs from protocols like HTTP REST: the server can also ask things of the host, it is not a one-way street. Three primitives travel in that direction.&lt;/p>
&lt;p>&lt;strong>Sampling&lt;/strong>: the server asks the host to run a generation with its LLM. That is, the server borrows the host&amp;rsquo;s LLM in order to reason.&lt;/p>
&lt;ul>
&lt;li>Example: the &lt;code>search-mcp&lt;/code> server receives a query from the agent, searches its corpus, finds 50 results and needs to summarise them before returning. Instead of having its own LLM, it sends a &lt;code>sampling/createMessage&lt;/code> to the client; the host passes this to its LLM, runs the generation with the user&amp;rsquo;s permissions, and returns the summary to the server.&lt;/li>
&lt;li>Architectural place: &lt;strong>the server asks for it, the host (with its LLM and the user&amp;rsquo;s authorisation) fulfils it&lt;/strong>.&lt;/li>
&lt;li>Why it matters: the user controls which model is used, what cost is paid, what permissions apply. The server does not need its own OpenAI API key.&lt;/li>
&lt;/ul>
&lt;p>&lt;strong>Roots&lt;/strong>: the host tells the server where to look. Roots are URIs (directories, repositories, namespaces) the host authorises the server to explore.&lt;/p>
&lt;ul>
&lt;li>Example: your Claude Desktop starts &lt;code>filesystem-mcp&lt;/code> with roots &lt;code>[file:///Users/me/projects]&lt;/code>. The server knows it must only operate inside that folder, not in &lt;code>/etc/passwd&lt;/code>.&lt;/li>
&lt;li>Architectural place: &lt;strong>the host declares them in the handshake, the server respects them&lt;/strong>.&lt;/li>
&lt;/ul>
&lt;p>&lt;strong>Elicitation&lt;/strong>: the server asks the host for additional information from the human user via a structured UI.&lt;/p>
&lt;ul>
&lt;li>Example: the &lt;code>stripe-mcp&lt;/code> server is about to process a refund of €5000. Before executing, it sends an &lt;code>elicitation/createMessage&lt;/code> to the client; the host shows the user &amp;ldquo;Confirm this €5000 refund&amp;rdquo; with a button; when the user confirms, it returns OK to the server, which then proceeds.&lt;/li>
&lt;li>Architectural place: &lt;strong>the server asks, the host shows the user, the user decides, the answer goes back to the server&lt;/strong>.&lt;/li>
&lt;li>It is the key primitive for human-in-the-loop on sensitive actions.&lt;/li>
&lt;/ul>
&lt;h3 id="visualising-the-flow-of-the-six-primitives">Visualising the flow of the six primitives&lt;/h3>
&lt;pre tabindex="0">&lt;code> HOST SERVER
│ │
Server-side ─────┼─────────────────────────────────────┤
│ │
tools/list ──────┼─────── asks what tools exist ──────▶│
│◀────── returns the list ────────────│
│ │
tools/call ──────┼────────── run this tool ───────────▶│
│◀────── result ──────────────────────│
│ │
resources/read ──┼────────── read this URI ───────────▶│
│◀────── content ─────────────────────│
│ │
prompts/get ─────┼─────── give me this prompt ────────▶│
│◀────── compiled prompt ─────────────│
│ │
Client-side ─────┼─────────────────────────────────────┤
│ │
sampling ────────│◀────── I need a generation ─────────│
│── use my LLM ───┐ │
│── returns ──────▼──────────────────▶│
│ │
roots ───────────┼───── declared in the handshake ────▶│
│ │
elicitation ─────│◀────── ask the user X ──────────────│
│── shows UI ────┐ │
│── confirms ────▼───────────────────▶│
&lt;/code>&lt;/pre>&lt;h2 id="json-rpc-in-action-a-concrete-example">JSON-RPC in action: a concrete example&lt;/h2>
&lt;p>To make the theory concrete, here is a real MCP conversation between a client and the &lt;code>filesystem-mcp&lt;/code> server:&lt;/p>
&lt;pre tabindex="0">&lt;code class="language-jsonc" data-lang="jsonc">// 1. Initial handshake (client → server)
{
&amp;#34;jsonrpc&amp;#34;: &amp;#34;2.0&amp;#34;, &amp;#34;id&amp;#34;: 1, &amp;#34;method&amp;#34;: &amp;#34;initialize&amp;#34;,
&amp;#34;params&amp;#34;: {
&amp;#34;protocolVersion&amp;#34;: &amp;#34;2026-03-01&amp;#34;,
&amp;#34;capabilities&amp;#34;: {
&amp;#34;sampling&amp;#34;: {}, // this client supports sampling
&amp;#34;roots&amp;#34;: { &amp;#34;listChanged&amp;#34;: true }
},
&amp;#34;clientInfo&amp;#34;: { &amp;#34;name&amp;#34;: &amp;#34;ClaudeDesktop&amp;#34;, &amp;#34;version&amp;#34;: &amp;#34;1.2.0&amp;#34; }
}
}
// 2. The server responds with its capabilities
{
&amp;#34;jsonrpc&amp;#34;: &amp;#34;2.0&amp;#34;, &amp;#34;id&amp;#34;: 1, &amp;#34;result&amp;#34;: {
&amp;#34;protocolVersion&amp;#34;: &amp;#34;2026-03-01&amp;#34;,
&amp;#34;capabilities&amp;#34;: {
&amp;#34;tools&amp;#34;: { &amp;#34;listChanged&amp;#34;: true },
&amp;#34;resources&amp;#34;: { &amp;#34;subscribe&amp;#34;: true, &amp;#34;listChanged&amp;#34;: true },
&amp;#34;prompts&amp;#34;: {}
},
&amp;#34;serverInfo&amp;#34;: { &amp;#34;name&amp;#34;: &amp;#34;filesystem-mcp&amp;#34;, &amp;#34;version&amp;#34;: &amp;#34;0.5.2&amp;#34; }
}
}
// 3. The client asks for the list of tools
{
&amp;#34;jsonrpc&amp;#34;: &amp;#34;2.0&amp;#34;, &amp;#34;id&amp;#34;: 2, &amp;#34;method&amp;#34;: &amp;#34;tools/list&amp;#34;
}
// 4. The server returns its tools with a schema
{
&amp;#34;jsonrpc&amp;#34;: &amp;#34;2.0&amp;#34;, &amp;#34;id&amp;#34;: 2, &amp;#34;result&amp;#34;: {
&amp;#34;tools&amp;#34;: [
{
&amp;#34;name&amp;#34;: &amp;#34;read_file&amp;#34;,
&amp;#34;description&amp;#34;: &amp;#34;Read a file from the filesystem&amp;#34;,
&amp;#34;inputSchema&amp;#34;: {
&amp;#34;type&amp;#34;: &amp;#34;object&amp;#34;,
&amp;#34;properties&amp;#34;: { &amp;#34;path&amp;#34;: { &amp;#34;type&amp;#34;: &amp;#34;string&amp;#34; } },
&amp;#34;required&amp;#34;: [&amp;#34;path&amp;#34;]
}
},
{ &amp;#34;name&amp;#34;: &amp;#34;write_file&amp;#34;, &amp;#34;description&amp;#34;: &amp;#34;...&amp;#34;, &amp;#34;inputSchema&amp;#34;: {} },
{ &amp;#34;name&amp;#34;: &amp;#34;list_directory&amp;#34;, &amp;#34;description&amp;#34;: &amp;#34;...&amp;#34;, &amp;#34;inputSchema&amp;#34;: {} }
]
}
}
// 5. The LLM decides to call read_file; the client sends tools/call
{
&amp;#34;jsonrpc&amp;#34;: &amp;#34;2.0&amp;#34;, &amp;#34;id&amp;#34;: 3, &amp;#34;method&amp;#34;: &amp;#34;tools/call&amp;#34;,
&amp;#34;params&amp;#34;: {
&amp;#34;name&amp;#34;: &amp;#34;read_file&amp;#34;,
&amp;#34;arguments&amp;#34;: { &amp;#34;path&amp;#34;: &amp;#34;/Users/me/projects/notes.md&amp;#34; },
&amp;#34;_meta&amp;#34;: { // ← the extension where trace context will go
&amp;#34;traceparent&amp;#34;: &amp;#34;00-abc123...-def456-01&amp;#34;
}
}
}
// 6. The server returns the file content
{
&amp;#34;jsonrpc&amp;#34;: &amp;#34;2.0&amp;#34;, &amp;#34;id&amp;#34;: 3, &amp;#34;result&amp;#34;: {
&amp;#34;content&amp;#34;: [
{ &amp;#34;type&amp;#34;: &amp;#34;text&amp;#34;, &amp;#34;text&amp;#34;: &amp;#34;# My notes\n\n...&amp;#34; }
]
}
}
&lt;/code>&lt;/pre>&lt;p>The important thing to note: &lt;strong>&lt;code>params._meta&lt;/code>&lt;/strong>. That is the bag where MCP conventionally passes cross-cutting metadata, including trace context. We will come back to it shortly.&lt;/p>
&lt;h2 id="the-observability-problem-why-traditional-tracing-is-not-enough">The observability problem: why traditional tracing is not enough&lt;/h2>
&lt;p>So much for theory. Let us go down to the operational problem: in a 2026 production cluster, a typical agent has 5-15 MCP servers connected simultaneously, each with 5-20 tools, and each conversation with the agent can generate dozens of chained tool calls. Without observability, debugging incidents is impossible.&lt;/p>
&lt;p>Why generic tracing (Hubble, OTel without MCP conventions) is not enough:&lt;/p>
&lt;p>&lt;strong>Stdio is not visible on the network&lt;/strong>. Local servers talk over OS pipes. Your Hubble or your Datadog APM see nothing; there are no packets to capture. AgentSight (seen in the &lt;a href="https://blog.lo0.es/en/posts/agentsight-llm-tracing-ebpf-zero-instrumentation/">previous post in the eBPF series&lt;/a>) with &lt;code>stdiocap&lt;/code> captures it but gives you the raw JSON-RPC, without semantic context (which tool it is, which resource, which prompt).&lt;/p>
&lt;p>&lt;strong>Generic HTTP does not understand MCP either&lt;/strong>. If you trace the HTTP to a remote MCP server without MCP conventions, you see a POST to &lt;code>/v1&lt;/code> with an opaque JSON-RPC body. You lose &amp;ldquo;which tool was invoked&amp;rdquo;, &amp;ldquo;what arguments&amp;rdquo;, &amp;ldquo;was it elicitation or sampling&amp;rdquo;. RED metrics per endpoint are no use to you; you need RED per tool.&lt;/p>
&lt;p>&lt;strong>JSON-RPC does not propagate trace context natively&lt;/strong>. Unlike HTTP (the W3C traceparent header) or gRPC (metadata), JSON-RPC has no standard field for trace context. If you do not propagate it, every call to the server starts a new trace disconnected from the agent&amp;rsquo;s trace.&lt;/p>
&lt;p>&lt;strong>Multistep multi-server is very hard to follow&lt;/strong>. A single user conversation can translate into: 1) a call to github-mcp &lt;code>get_pr&lt;/code>; 2) a call to filesystem-mcp &lt;code>read_file&lt;/code> for several files; 3) a call to the main LLM with all the context; 4) a call to postgres-mcp &lt;code>query&lt;/code>; 5) a call to slack-mcp &lt;code>send_message&lt;/code>. Without propagated trace context, those are five disconnected traces. With propagation, it is one tree.&lt;/p>
&lt;p>The solution: &lt;strong>OpenTelemetry semantic conventions for MCP&lt;/strong>, already stable in 2026.&lt;/p>
&lt;h2 id="opentelemetry-semantic-conventions-for-mcp">OpenTelemetry semantic conventions for MCP&lt;/h2>
&lt;p>The &lt;a href="https://opentelemetry.io/docs/specs/semconv/gen-ai/mcp/">GenAI MCP semantic conventions&lt;/a> are the set of standardised attributes for MCP-related spans and metrics. They were published as part of the GenAI subgroup of the OpenTelemetry SIG and are the first part of the GenAI semantic conventions to reach stable.&lt;/p>
&lt;h3 id="why-mcp-specific-semantic-conventions">Why MCP-specific semantic conventions&lt;/h3>
&lt;p>Before they existed, teams instrumented MCP with the generic &lt;strong>RPC semantic conventions&lt;/strong> (the ones you would use for gRPC or XML-RPC). It half worked. The MCP-specific conventions add:&lt;/p>
&lt;ul>
&lt;li>Attributes to identify which primitive was executed (&lt;code>mcp.method.name = &amp;quot;tools/call&amp;quot;&lt;/code>).&lt;/li>
&lt;li>Attributes to identify which specific tool/resource/prompt was touched (&lt;code>mcp.tool.name&lt;/code>, &lt;code>mcp.resource.uri&lt;/code>, &lt;code>mcp.prompt.name&lt;/code>).&lt;/li>
&lt;li>Attributes for the bidirectional flow (sampling/elicitation requests from the server to the client).&lt;/li>
&lt;li>Attributes for the handshake (&lt;code>mcp.protocol.version&lt;/code>, &lt;code>mcp.client.name&lt;/code>, &lt;code>mcp.server.name&lt;/code>).&lt;/li>
&lt;li>Standardised RED metrics per tool (&lt;code>mcp.tool.call.duration&lt;/code>, &lt;code>mcp.tool.call.errors&lt;/code>).&lt;/li>
&lt;/ul>
&lt;h3 id="the-canonical-attributes">The canonical attributes&lt;/h3>
&lt;p>The attributes any MCP-aware instrumentation should emit:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Attribute&lt;/th>
&lt;th>Meaning&lt;/th>
&lt;th>Example&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>&lt;code>mcp.method.name&lt;/code>&lt;/td>
&lt;td>JSON-RPC method&lt;/td>
&lt;td>&lt;code>&amp;quot;tools/call&amp;quot;&lt;/code>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>mcp.tool.name&lt;/code>&lt;/td>
&lt;td>Name of the tool&lt;/td>
&lt;td>&lt;code>&amp;quot;read_file&amp;quot;&lt;/code>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>mcp.resource.uri&lt;/code>&lt;/td>
&lt;td>URI of the resource&lt;/td>
&lt;td>&lt;code>&amp;quot;file:///docs/api.md&amp;quot;&lt;/code>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>mcp.prompt.name&lt;/code>&lt;/td>
&lt;td>Name of the prompt&lt;/td>
&lt;td>&lt;code>&amp;quot;code_review&amp;quot;&lt;/code>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>mcp.session.id&lt;/code>&lt;/td>
&lt;td>MCP session ID&lt;/td>
&lt;td>&lt;code>&amp;quot;sess-abc123&amp;quot;&lt;/code>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>mcp.protocol.version&lt;/code>&lt;/td>
&lt;td>Protocol version&lt;/td>
&lt;td>&lt;code>&amp;quot;2026-03-01&amp;quot;&lt;/code>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>mcp.client.name&lt;/code>&lt;/td>
&lt;td>Client identity&lt;/td>
&lt;td>&lt;code>&amp;quot;ClaudeDesktop/1.2.0&amp;quot;&lt;/code>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>mcp.server.name&lt;/code>&lt;/td>
&lt;td>Server identity&lt;/td>
&lt;td>&lt;code>&amp;quot;filesystem-mcp/0.5.2&amp;quot;&lt;/code>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>mcp.transport&lt;/code>&lt;/td>
&lt;td>Transport used&lt;/td>
&lt;td>&lt;code>&amp;quot;stdio&amp;quot;&lt;/code> or &lt;code>&amp;quot;http&amp;quot;&lt;/code>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>mcp.error.code&lt;/code>&lt;/td>
&lt;td>JSON-RPC error code&lt;/td>
&lt;td>&lt;code>-32602&lt;/code> (Invalid params)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>gen_ai.usage.input_tokens&lt;/code>&lt;/td>
&lt;td>Tokens consumed (if sampling)&lt;/td>
&lt;td>&lt;code>1240&lt;/code>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>gen_ai.usage.output_tokens&lt;/code>&lt;/td>
&lt;td>Tokens generated (if sampling)&lt;/td>
&lt;td>&lt;code>512&lt;/code>&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>The last two come from the generic GenAI semantic conventions and apply when the MCP call involves sampling (the server using the client&amp;rsquo;s LLM).&lt;/p>
&lt;h3 id="red-metrics-per-tool">RED metrics per tool&lt;/h3>
&lt;p>Beyond spans, the semantic conventions define three core metrics:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>&lt;code>mcp.tool.call.duration&lt;/code>&lt;/strong> (histogram): the latency of each invocation.&lt;/li>
&lt;li>&lt;strong>&lt;code>mcp.tool.call.count&lt;/code>&lt;/strong> (counter): the total number of invocations.&lt;/li>
&lt;li>&lt;strong>&lt;code>mcp.tool.call.errors&lt;/code>&lt;/strong> (counter): errors per tool.&lt;/li>
&lt;/ul>
&lt;p>Labelled with &lt;code>mcp.tool.name&lt;/code>, &lt;code>mcp.server.name&lt;/code>, &lt;code>mcp.client.name&lt;/code>. Pivotable in Grafana to answer &amp;ldquo;which tool is the slowest&amp;rdquo;, &amp;ldquo;which tool fails most&amp;rdquo;, &amp;ldquo;which client loads which server hardest&amp;rdquo;.&lt;/p>
&lt;h2 id="trace-context-propagation-the-params_meta-trick">Trace context propagation: the &lt;code>params._meta&lt;/code> trick&lt;/h2>
&lt;p>JSON-RPC has no headers like HTTP, so MCP cannot use the W3C &lt;code>traceparent&lt;/code> header directly. The solution the ecosystem has converged on: &lt;strong>propagate trace context in &lt;code>params._meta&lt;/code>&lt;/strong>.&lt;/p>
&lt;p>When the MCP client sends a &lt;code>tools/call&lt;/code>, its OTel instrumentation does:&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">json&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">opentelemetry.propagate&lt;/span> &lt;span class="kn">import&lt;/span> &lt;span class="n">inject&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">carrier&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">inject&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">carrier&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="c1"># fills it with traceparent/tracestate from the active span&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">params&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;name&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;read_file&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;arguments&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="p">{&lt;/span>&lt;span class="s2">&amp;#34;path&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;/notes.md&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;_meta&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="n">carrier&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="c1"># ← propagates trace context&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>When the server receives it, it does the symmetrical thing:&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">opentelemetry.propagate&lt;/span> &lt;span class="kn">import&lt;/span> &lt;span class="n">extract&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">ctx&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">extract&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">request&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">params&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">get&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;_meta&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">with&lt;/span> &lt;span class="n">tracer&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">start_as_current_span&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;tools/call&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">context&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">ctx&lt;/span>&lt;span class="p">):&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="c1"># this span is a child of the client&amp;#39;s&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">execute_tool&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">request&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">params&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>The result: the server&amp;rsquo;s span is a child of the client&amp;rsquo;s span in the trace tree. When you look at the trace in Tempo or Phoenix, you see the whole chain: user → host → client → server → execution → response → client → host → response to the user.&lt;/p>
&lt;p>This requires both ends to instrument consistently. If the server does not extract the context, you see disconnected spans but at least you have traceability on the client side.&lt;/p>
&lt;h2 id="instrumentation-patterns">Instrumentation patterns&lt;/h2>
&lt;p>There are three routes to instrumenting MCP, in increasing order of effort:&lt;/p>
&lt;h3 id="1-fastmcp-with-built-in-opentelemetry">1. FastMCP with built-in OpenTelemetry&lt;/h3>
&lt;p>&lt;a href="https://gofastmcp.com/">FastMCP&lt;/a> is one of the most used Python frameworks for building MCP servers. It ships built-in OpenTelemetry instrumentation: every tool, resource template and prompt operation generates spans automatically with the correct MCP conventions.&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">fastmcp&lt;/span> &lt;span class="kn">import&lt;/span> &lt;span class="n">FastMCP&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">opentelemetry.sdk.trace.export&lt;/span> &lt;span class="kn">import&lt;/span> &lt;span class="n">OTLPSpanExporter&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">mcp&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">FastMCP&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;my-server&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">otel_endpoint&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;https://otel-collector:4318&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="nd">@mcp.tool&lt;/span>&lt;span class="p">()&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">search_docs&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">query&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="s2">&amp;#34;&amp;#34;&amp;#34;Search the corpus for matching documents.&amp;#34;&amp;#34;&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="c1"># this automatically generates a span with&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="c1"># mcp.tool.name=search_docs, mcp.method.name=tools/call, etc.&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">run_search&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">query&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Zero instrumentation code. Spans with the correct conventions. It is the recommended pattern if you are starting an MCP server in Python from scratch.&lt;/p>
&lt;h3 id="2-manual-opentelemetry-sdk">2. Manual OpenTelemetry SDK&lt;/h3>
&lt;p>For servers that already exist or are in other languages (TypeScript, Go), the option is to instrument manually with the standard OTel SDK and emit the conventional MCP attributes:&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">opentelemetry&lt;/span> &lt;span class="kn">import&lt;/span> &lt;span class="n">trace&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">tracer&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">trace&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">get_tracer&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="vm">__name__&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">async&lt;/span> &lt;span class="k">def&lt;/span> &lt;span class="nf">handle_tools_call&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">req&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="n">JSONRPCRequest&lt;/span>&lt;span class="p">):&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">ctx&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">extract_trace_context&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">req&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">tracer&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">start_as_current_span&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;mcp.tools.call&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">context&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">ctx&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="k">as&lt;/span> &lt;span class="n">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">span&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">set_attribute&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;mcp.method.name&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s2">&amp;#34;tools/call&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">span&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">set_attribute&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;mcp.tool.name&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">req&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">params&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;name&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">span&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">set_attribute&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;mcp.server.name&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s2">&amp;#34;filesystem-mcp&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">try&lt;/span>&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">execute_tool&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">req&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">params&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">result&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">except&lt;/span> &lt;span class="ne">Exception&lt;/span> &lt;span class="k">as&lt;/span> &lt;span class="n">e&lt;/span>&lt;span class="p">:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">span&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">set_attribute&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;mcp.error.code&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="o">-&lt;/span>&lt;span class="mi">32603&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">span&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">record_exception&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">e&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">raise&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>More boilerplate but it works with any existing server.&lt;/p>
&lt;h3 id="3-mcp-inspector-for-interactive-debugging">3. MCP Inspector for interactive debugging&lt;/h3>
&lt;p>&lt;a href="https://github.com/modelcontextprotocol/inspector">MCP Inspector&lt;/a> (official) is a tool for interactive protocol-level debugging. It launches a local proxy (port 6277) between your client and the server, and opens a web UI (port 6274) where you see every JSON-RPC message back and forth in real time.&lt;/p>
&lt;p>It is not production observability, it is development and debugging. But it is irreplaceable during the bring-up of a new server: you see exactly which requests arrive, which responses are returned, which errors occur. It saves hours of ad-hoc logging.&lt;/p>
&lt;h2 id="mcp-gateways-the-centralised-piece-for-enterprise">MCP Gateways: the centralised piece for enterprise&lt;/h2>
&lt;p>When your organisation has many agents connecting to many MCP servers, managing the connection matrix becomes operationally serious. The natural question, &amp;ldquo;can there be a proxy in front of all the MCP servers that centralises auth, rate limiting, logging and observability?&amp;rdquo;, already has an answer: &lt;strong>MCP Gateways&lt;/strong>.&lt;/p>
&lt;p>An MCP Gateway is a proxy that:&lt;/p>
&lt;ul>
&lt;li>Accepts MCP connections from the hosts/agents.&lt;/li>
&lt;li>Routes them to the corresponding backend MCP servers.&lt;/li>
&lt;li>Applies centralised &lt;strong>authentication and authorisation&lt;/strong> (which agent can call which tool).&lt;/li>
&lt;li>Applies &lt;strong>rate limiting&lt;/strong> per agent, per tool, per tenant.&lt;/li>
&lt;li>&lt;strong>Observes&lt;/strong>: it emits OTel metrics for every operation passing through.&lt;/li>
&lt;li>&lt;strong>Propagates the identity&lt;/strong> of the agent to the backend server (with several models: token forwarding, token exchange, impersonation).&lt;/li>
&lt;/ul>
&lt;p>The options that have established themselves in 2026:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>&lt;a href="https://doc.traefik.io/traefik-hub/mcp-gateway/">Traefik Hub MCP Gateway&lt;/a>&lt;/strong> — from the Traefik team. Declarative configuration, native integration with Traefik&amp;rsquo;s Kubernetes/Helm ecosystem.&lt;/li>
&lt;li>&lt;strong>&lt;a href="https://www.mintmcp.com/">MintMCP&lt;/a>&lt;/strong> — a gateway focused on observability and multi-tenancy. SaaS and self-host.&lt;/li>
&lt;li>&lt;strong>&lt;a href="https://openobserve.ai/blog/mcp-gateway-guide/">OpenObserve MCP Gateway&lt;/a>&lt;/strong> — integrated with the OpenObserve observability platform.&lt;/li>
&lt;/ul>
&lt;p>For small deployments (one team, few agents) a Gateway can be overkill. For enterprise (dozens of agents, dozens of servers, regulated compliance), it is practically mandatory.&lt;/p>
&lt;h2 id="real-use-cases-for-mcp-observability">Real use cases for MCP observability&lt;/h2>
&lt;p>Let us land this with five cases where properly instrumented MCP observability delivers immediate value:&lt;/p>
&lt;h3 id="1-audit-per-tool-per-tenant-per-agent">1. Audit per tool, per tenant, per agent&lt;/h3>
&lt;p>Question: &amp;ldquo;who ran the &lt;code>delete_repo&lt;/code> tool last month?&amp;rdquo;. Without MCP observability, impossible. With OTel conventions + identity propagation: a query in your trace backend filtering by &lt;code>mcp.tool.name=&amp;quot;delete_repo&amp;quot;&lt;/code>, grouping by &lt;code>mcp.client.name&lt;/code> or by the user_id propagated in &lt;code>_meta&lt;/code>. Compliance happy.&lt;/p>
&lt;h3 id="2-cost-per-tool-and-per-tenant">2. Cost per tool and per tenant&lt;/h3>
&lt;p>Question: &amp;ldquo;how much does each tool cost?&amp;rdquo;. If the tools invoke external APIs (Stripe, OpenAI sampling) or consume significant resources (a GPU for an inference tool), knowing their aggregated cost matters. With &lt;code>mcp.tool.call.duration&lt;/code> + &lt;code>gen_ai.usage.*&lt;/code> aggregated by tool and tenant, you build cost accountability dashboards without instrumenting anything extra.&lt;/p>
&lt;h3 id="3-debugging-multistep-chains-that-fail">3. Debugging multistep chains that fail&lt;/h3>
&lt;p>Question: &amp;ldquo;the agent failed to complete this task, where did it go wrong?&amp;rdquo;. The propagated trace connects: the user&amp;rsquo;s span → the LLM&amp;rsquo;s span with its CoT → the spans of each tool invoked → the final LLM span. If the chain broke at the third tool, in Tempo you see the red span with the specific error message. Reproducing the failure is trivial.&lt;/p>
&lt;h3 id="4-tool-latency-and-degradation">4. Tool latency and degradation&lt;/h3>
&lt;p>Question: &amp;ldquo;which tool is degrading?&amp;rdquo;. RED metrics per tool in Grafana show p95/p99 latency over time. When a tool starts climbing from 200ms to 800ms (because the underlying service is collapsing), you see it before the users complain.&lt;/p>
&lt;h3 id="5-detecting-loops-and-agentic-anomalies">5. Detecting loops and agentic anomalies&lt;/h3>
&lt;p>Question: &amp;ldquo;is any agent stuck in a loop?&amp;rdquo;. If an agent calls &lt;code>tools/call read_file&lt;/code> 80 times in 30 seconds for the same path, something is clearly wrong. An alert on &lt;code>mcp.tool.call.count&lt;/code> grouped by (session_id, tool_name) detects this. Combined with loop detection at the reasoning level, it closes the circle.&lt;/p>
&lt;h2 id="operational-pitfalls">Operational pitfalls&lt;/h2>
&lt;h3 id="lack-of-identity-propagation">Lack of identity propagation&lt;/h3>
&lt;p>Your Gateway authenticates the agent, but passes requests to the backend without propagating identity. The result: the backend&amp;rsquo;s logs say &amp;ldquo;service-account&amp;rdquo; for everything, and it is impossible to audit who invoked what. &lt;strong>Choose a propagation strategy early&lt;/strong>: token forwarding (simple, exposes tokens to the backend), token exchange (more secure), or impersonation with cross-logging.&lt;/p>
&lt;h3 id="stdio-servers-that-do-not-show-up-in-your-apm">Stdio servers that do not show up in your APM&lt;/h3>
&lt;p>This is the field&amp;rsquo;s number 1 pitfall. Your Cursor agent uses filesystem-mcp over stdio; you see nothing in Datadog because there is no network traffic. Solution: instrument the stdio server with an OTel SDK that exports over OTLP to your collector (via gRPC or HTTP; the OTel collector can receive even though the server talks stdio to its client). Or use AgentSight &lt;code>stdiocap&lt;/code> to capture the raw JSON-RPC and process it offline.&lt;/p>
&lt;h3 id="multiple-protocol-versions-in-production">Multiple protocol versions in production&lt;/h3>
&lt;p>Different clients use different MCP versions simultaneously. Your metrics dashboard mixes apples and oranges. ALWAYS label with &lt;code>mcp.protocol.version&lt;/code> and filter/group by it.&lt;/p>
&lt;h3 id="_meta-lost-when-passing-through-a-proxy">&lt;code>_meta&lt;/code> lost when passing through a proxy&lt;/h3>
&lt;p>Your Gateway accepts the client&amp;rsquo;s request, rewrites it for the backend, and forgets to copy &lt;code>params._meta&lt;/code>. The result: the trace is broken at the Gateway, two disconnected traces. Make sure your Gateway &lt;strong>preserves or re-injects&lt;/strong> trace context at each hop.&lt;/p>
&lt;h3 id="trace-volume-with-chatty-servers">Trace volume with chatty servers&lt;/h3>
&lt;p>Some MCP servers emit many small operations (filesystem listings, partial reads). Without sampling, they fill your backend with useless traces. Apply &lt;strong>tail-based sampling&lt;/strong> that keeps complete sessions or keeps only traces with errors or high latency.&lt;/p>
&lt;h3 id="cardinality-in-metrics">Cardinality in metrics&lt;/h3>
&lt;p>&lt;code>mcp.tool.call.duration&lt;/code> with &lt;code>mcp.session.id&lt;/code> as a label blows up cardinality. &lt;strong>Do not include unique per-session IDs in labels&lt;/strong>; keep cardinality under control with labels that take few discrete values (tool name, server name, client name, error code).&lt;/p>
&lt;h3 id="confusing-client-and-server-spans">Confusing client and server spans&lt;/h3>
&lt;p>When you look at the tree, distinguish: the client sees the total latency from its perspective (network included); the server sees only its own work. If you look only at the server span to debug the latency perceived by the user, you miss the RTT. Use both.&lt;/p>
&lt;h2 id="what-we-have-not-covered">What we have not covered&lt;/h2>
&lt;ul>
&lt;li>&lt;strong>Experimental WebSocket MCP transport&lt;/strong>: an alternative to Streamable HTTP, not yet standard.&lt;/li>
&lt;li>&lt;strong>MCP servers in cloud-native deployments with sidecars&lt;/strong>: an emerging pattern of deploying MCP servers as pod sidecars.&lt;/li>
&lt;li>&lt;strong>MCP federation&lt;/strong>: composing several servers as one (similar to GraphQL federation).&lt;/li>
&lt;li>&lt;strong>eBPF + MCP&lt;/strong>: how AgentSight&amp;rsquo;s &lt;code>stdiocap&lt;/code> and Cilium&amp;rsquo;s hooks complement native instrumentation.&lt;/li>
&lt;li>&lt;strong>MCP testing and contract tests&lt;/strong>: how to validate that your server complies with the spec.&lt;/li>
&lt;/ul>
&lt;h2 id="references">References&lt;/h2>
&lt;p>Specification and concepts:&lt;/p>
&lt;ul>
&lt;li>&lt;a href="https://modelcontextprotocol.io/">Model Context Protocol — sitio oficial&lt;/a> — entrada canónica.&lt;/li>
&lt;li>&lt;a href="https://modelcontextprotocol.io/docs/learn/architecture">MCP architecture overview&lt;/a>.&lt;/li>
&lt;li>&lt;a href="https://modelcontextprotocol.info/docs/concepts/transports/">Transports — MCP docs&lt;/a>.&lt;/li>
&lt;li>&lt;a href="https://github.com/modelcontextprotocol/inspector">MCP Inspector (GitHub)&lt;/a> — debugging interactivo.&lt;/li>
&lt;/ul>
&lt;p>OpenTelemetry GenAI MCP:&lt;/p>
&lt;ul>
&lt;li>&lt;a href="https://opentelemetry.io/docs/specs/semconv/gen-ai/mcp/">Semantic conventions for Model Context Protocol — OpenTelemetry&lt;/a> — referencia normativa.&lt;/li>
&lt;li>&lt;a href="https://github.com/modelcontextprotocol/modelcontextprotocol/discussions/269">Adding OpenTelemetry Trace Support to MCP (Discussion #269)&lt;/a> — historia de la propuesta.&lt;/li>
&lt;li>&lt;a href="https://oneuptime.com/blog/post/2026-03-26-how-to-instrument-mcp-servers-with-opentelemetry/view">How to Instrument MCP Servers with OpenTelemetry (OneUptime)&lt;/a>.&lt;/li>
&lt;li>&lt;a href="https://www.elastic.co/observability-labs/blog/mcp-tracing-opentelemetry-elastic-apm">How to trace MCP server tool calls with OpenTelemetry and Elastic APM&lt;/a>.&lt;/li>
&lt;li>&lt;a href="https://signoz.io/blog/mcp-observability-with-otel/">MCP Observability with OpenTelemetry (SigNoz)&lt;/a>.&lt;/li>
&lt;li>&lt;a href="https://developers.redhat.com/articles/2026/04/06/distributed-tracing-agentic-workflows-opentelemetry">Distributed tracing for agentic workflows (Red Hat Developer)&lt;/a>.&lt;/li>
&lt;li>&lt;a href="https://www.mintmcp.com/blog/opentelemetry-ai-agents">OpenTelemetry for AI Agents in MCP Workflows (MintMCP)&lt;/a>.&lt;/li>
&lt;/ul>
&lt;p>Frameworks and gateways:&lt;/p>
&lt;ul>
&lt;li>&lt;a href="https://gofastmcp.com/servers/telemetry">FastMCP OpenTelemetry&lt;/a> — instrumentación built-in.&lt;/li>
&lt;li>&lt;a href="https://doc.traefik.io/traefik-hub/mcp-gateway/">Traefik Hub MCP Gateway&lt;/a> — gateway de Traefik.&lt;/li>
&lt;li>&lt;a href="https://www.mintmcp.com/">MintMCP&lt;/a> — gateway con foco en observabilidad.&lt;/li>
&lt;li>&lt;a href="https://openobserve.ai/blog/mcp-gateway-guide/">OpenObserve MCP Gateway guide&lt;/a>.&lt;/li>
&lt;li>&lt;a href="https://dev.to/composiodev/what-is-an-mcp-gateway-and-why-do-enterprise-ai-teams-need-one-in-2026-1lie">What is an MCP Gateway (DEV Community)&lt;/a>.&lt;/li>
&lt;li>&lt;a href="https://github.com/traceloop/opentelemetry-mcp-server">OpenTelemetry MCP Server (Traceloop)&lt;/a> — el patrón inverso: usar MCP para que agentes consulten traces OTel.&lt;/li>
&lt;/ul>
&lt;p>Cross-references:&lt;/p>
&lt;ul>
&lt;li>Post anterior: &lt;a href="https://blog.lo0.es/en/posts/llm-guardrails-safety-four-lines-of-defence/">Guardrails y safety&lt;/a>.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/agentsight-llm-tracing-ebpf-zero-instrumentation/">AgentSight y el nuevo tracing de LLMs&lt;/a> — donde se introdujo &lt;code>stdiocap&lt;/code> para capturar stdio de servidores MCP locales.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/evals-llm-layer-after-tracing/">Evals: la capa después del tracing&lt;/a>.&lt;/li>
&lt;/ul></description></item><item><title>AgentSight and the new LLM tracing: zero-instrumentation with eBPF against Langfuse, LangSmith, Phoenix and company</title><link>https://blog.lo0.es/en/posts/agentsight-llm-tracing-ebpf-zero-instrumentation/</link><pubDate>Tue, 19 May 2026 18:00:00 +0200</pubDate><guid>https://blog.lo0.es/en/posts/agentsight-llm-tracing-ebpf-zero-instrumentation/</guid><description>&lt;h2 id="tldr">TL;DR&lt;/h2>
&lt;p>Observing an LLM agent in production in 2026 splits into two approaches with opposite philosophies. The &lt;strong>instrumented&lt;/strong> one, dominant until 2025, lives in tools such as &lt;a href="https://langfuse.com/">Langfuse&lt;/a>, &lt;a href="https://www.langchain.com/langsmith">LangSmith&lt;/a>, &lt;a href="https://phoenix.arize.com/">Arize Phoenix&lt;/a>, &lt;a href="https://www.helicone.ai/">Helicone&lt;/a>, &lt;a href="https://www.traceloop.com/openllmetry">OpenLLMetry/Traceloop&lt;/a> or &lt;a href="https://pydantic.dev/logfire">Pydantic Logfire&lt;/a>: you install an SDK, decorate your calls, emit spans with the &lt;strong>OpenTelemetry GenAI&lt;/strong> convention (&lt;code>gen_ai.request.model&lt;/code>, &lt;code>gen_ai.usage.input_tokens&lt;/code>, and so on) and export them to a backend. Enormous depth when you control the code; zero visibility when the agent is an opaque binary you run without instrumenting. The &lt;strong>zero-instrumentation&lt;/strong> one, which &lt;a href="https://github.com/eunomia-bpf/agentsight">AgentSight&lt;/a> popularised in the second half of 2025, turns the perspective 180º: it puts &lt;strong>eBPF hooks on the uprobes of the SSL/TLS libraries&lt;/strong> and captures the plaintext of every HTTPS request before encryption, &lt;strong>without touching the app&amp;rsquo;s code&lt;/strong>, with less than &lt;strong>3% overhead&lt;/strong> and the guarantee of being &lt;strong>tamper-proof&lt;/strong> (the agent cannot falsify what is seen in the kernel). Combined with BPF capture of stdio for local MCP servers, AgentSight gives you complete observability of any agent, including closed binaries such as Claude Code, Gemini CLI or Cursor, on a Kubernetes cluster. The two families are not enemies: the 2026 reference stack combines both (instrumented for your own apps with LangChain, eBPF for opaque binaries and tamper-proof compliance) over &lt;strong>OpenTelemetry GenAI semantic conventions&lt;/strong> as the common vocabulary the ecosystem is stabilising this year.&lt;/p>
&lt;blockquote>
&lt;p>This is the &lt;strong>fourth and last post of the eBPF series&lt;/strong>. Part 1: &lt;a href="https://blog.lo0.es/en/posts/ebpf-zero-to-cilium-kernel-tcp-ip-bypass/">eBPF from zero to Cilium&lt;/a>. Part 2: &lt;a href="https://blog.lo0.es/en/posts/tetragon-cilium-security-syscalls-kernel/">Tetragon: runtime security&lt;/a>. Part 3: &lt;a href="https://blog.lo0.es/en/posts/hubble-ebpf-network-observability-ai-agents/">Hubble: network observability&lt;/a>. Here we close the circle with the &lt;strong>semantic&lt;/strong> dimension: what an AI agent does, not only what network it opens or what syscalls it emits.&lt;/p>
&lt;/blockquote>
&lt;h2 id="the-analogy-traditional-apm-vs-network-sniffer">The analogy: traditional APM vs network sniffer&lt;/h2>
&lt;p>Anyone who has operated enterprise applications knows the two tribes of monitoring. The &lt;strong>APM&lt;/strong> tribe (New Relic, AppDynamics, Datadog APM): you install an agent or an SDK in each application, mark spans, collect traces with enormous depth inside each process, down to lines of code, SQL queries, Java methods. The &lt;strong>wire-level&lt;/strong> tribe (network sniffers, SolarWinds NPM-style tools, NetFlow): it does not touch the application; it observes the cable, sees protocols, latencies, retransmissions, identifies problems the app does not know it has.&lt;/p>
&lt;p>Each one sees different things and both are useful. Anyone who has lived through a serious incident where APM said &amp;ldquo;all green&amp;rdquo; while users suffered knows that wire-level would have caught the problem (a saturated middlebox, a badly configured MTU, a TCP timeout). Anyone who has tried to debug a memory leak with sniffers knows it was impossible without APM.&lt;/p>
&lt;p>LLM agent observability in 2026 is exactly at this point. The &lt;strong>APM-style&lt;/strong> has been in place for a couple of years: Langfuse, LangSmith, Phoenix, OpenLLMetry. Enormous depth, requires instrumenting the app. The &lt;strong>wire-level with eBPF&lt;/strong> has just arrived: AgentSight is the first project to take it to production. Less depth inside the agent, but it sees any agent without touching anything and it is &lt;strong>tamper-proof&lt;/strong>. Both are useful. The industry is in full coexistence.&lt;/p>
&lt;h2 id="why-observing-llm-agents-is-different">Why observing LLM agents is different&lt;/h2>
&lt;p>Before getting into tools, it is worth pausing on what makes LLM agents specific as subjects of observability:&lt;/p>
&lt;p>&lt;strong>Non-determinism.&lt;/strong> The same input can produce different outputs. Reproducing an incident requires capturing &lt;strong>exactly&lt;/strong> the conversation, the model, the parameters and, ideally, the seed. An aggregate &amp;ldquo;p95 latency&amp;rdquo; metric falls short; what you need is replay of the individual trace.&lt;/p>
&lt;p>&lt;strong>A chain of external invocations.&lt;/strong> A typical agent calls LLM → tools (tool calling) → MCP servers → other APIs → back to the LLM. A chat session can generate &lt;strong>dozens of chained calls&lt;/strong> that have to be correlated by trace_id to understand the decision.&lt;/p>
&lt;p>&lt;strong>Cost linear in tokens.&lt;/strong> Every call is paid for in tokens. Without tracing input/output tokens per request, you cannot assign cost to a tenant or a team, nor detect loops that eat your budget in an hour.&lt;/p>
&lt;p>&lt;strong>Semantic risk.&lt;/strong> Prompt injection (a user input containing instructions to manipulate the model), jailbreaks, secret leakage through tool calls. It is a class of problem that does not appear in traditional applications, and observability must see it.&lt;/p>
&lt;p>&lt;strong>Opaque binaries.&lt;/strong> In 2026, many teams deploy &lt;strong>third-party agents&lt;/strong> such as Claude Code, the Cursor agent, Aider, Gemini CLI or Codex CLI as internal tools. They are not your own applications; they are closed binaries that call the vendor&amp;rsquo;s API. Instrumenting them is impossible. Observing them requires something else.&lt;/p>
&lt;p>&lt;strong>Multi-agent and orchestration.&lt;/strong> More and more architectures have agents that invoke other agents (planner → executor → critic). Observability must understand the topology, not just the individual span.&lt;/p>
&lt;p>With these five points in mind, the tools we are about to see differ mainly in &lt;strong>which parts&lt;/strong> of the problem they cover well and &lt;strong>which parts&lt;/strong> they leave blind.&lt;/p>
&lt;h2 id="the-instrumented-approach-how-it-works">The instrumented approach: how it works&lt;/h2>
&lt;p>The model is direct and familiar:&lt;/p>
&lt;ol>
&lt;li>Your code calls the LLM or the tools using an official library: &lt;code>openai&lt;/code>, &lt;code>anthropic&lt;/code>, &lt;code>langchain&lt;/code>, &lt;code>llama_index&lt;/code>, &lt;code>dspy&lt;/code>.&lt;/li>
&lt;li>You install a tracer SDK (Langfuse, LangSmith, OpenLLMetry, Logfire) that &lt;strong>wraps&lt;/strong> or &lt;strong>monkey-patches&lt;/strong> those libraries.&lt;/li>
&lt;li>Each call emits an &lt;strong>OpenTelemetry span&lt;/strong> with standardised attributes: model used, input/output tokens, latency, parameters, messages, tool invoked, result.&lt;/li>
&lt;li>The spans are exported over OTLP to a backend that shows them as a tree of traces.&lt;/li>
&lt;/ol>
&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"># Typical example with OpenLLMetry + any SDK&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">traceloop.sdk&lt;/span> &lt;span class="kn">import&lt;/span> &lt;span class="n">Traceloop&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">openai&lt;/span> &lt;span class="kn">import&lt;/span> &lt;span class="n">OpenAI&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">Traceloop&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">init&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">app_name&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;my-agent&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">api_endpoint&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;https://otel-collector:4318&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="n">client&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">OpenAI&lt;/span>&lt;span class="p">()&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># this call automatically emits a span with&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># gen_ai.request.model, gen_ai.usage.input_tokens, etc.&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">resp&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">client&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">chat&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">completions&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">create&lt;/span>&lt;span class="p">(&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">model&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;gpt-4.1&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">messages&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="p">[{&lt;/span>&lt;span class="s2">&amp;#34;role&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;user&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s2">&amp;#34;content&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;...&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>What you see afterwards: a dashboard with each conversation as a trace, each call as a span, the complete prompts and completions (if you opt in), the computed cost, latencies per span, errors flagged.&lt;/p>
&lt;h3 id="opentelemetry-genai-semantic-conventions-the-common-vocabulary">OpenTelemetry GenAI semantic conventions: the common vocabulary&lt;/h3>
&lt;p>The fragmentation of the field is being mitigated with &lt;a href="https://opentelemetry.io/docs/specs/semconv/gen-ai/">&lt;strong>OpenTelemetry GenAI Semantic Conventions&lt;/strong>&lt;/a>. It is the CNCF&amp;rsquo;s effort to get &lt;strong>all&lt;/strong> tools to emit spans with the same attribute names:&lt;/p>
&lt;ul>
&lt;li>&lt;code>gen_ai.system&lt;/code> — the provider (openai, anthropic, vertex_ai, and so on).&lt;/li>
&lt;li>&lt;code>gen_ai.request.model&lt;/code> — the model requested (&lt;code>gpt-4.1&lt;/code>, &lt;code>claude-3-5-sonnet&lt;/code>).&lt;/li>
&lt;li>&lt;code>gen_ai.response.model&lt;/code> — the model actually used (sometimes it differs, e.g. fallbacks).&lt;/li>
&lt;li>&lt;code>gen_ai.usage.input_tokens&lt;/code> and &lt;code>gen_ai.usage.output_tokens&lt;/code> — counters.&lt;/li>
&lt;li>&lt;code>gen_ai.request.temperature&lt;/code>, &lt;code>gen_ai.request.top_p&lt;/code>, and so on — parameters.&lt;/li>
&lt;li>&lt;code>gen_ai.response.finish_reasons&lt;/code> — why it ended (stop, length, content_filter).&lt;/li>
&lt;li>&lt;code>gen_ai.operation.name&lt;/code> — the type of operation (chat, embedding, completion).&lt;/li>
&lt;/ul>
&lt;p>In early 2026, the &lt;strong>client spans&lt;/strong> moved from experimental to stable. The rest (server spans, multi-agent events) is still in development. The operational meaning: if your SDK emits these attributes, &lt;strong>any backend that understands OTel GenAI&lt;/strong> can consume them. Moving from Langfuse to Phoenix to Helicone does not imply re-instrumenting, only changing the exporter.&lt;/p>
&lt;p>The SIG is actively developing &lt;strong>conventions for multi-agent systems&lt;/strong>: agent teams, tasks, actions, memory, artifact tracking. This is what is missing for complex agent architectures to have a common vocabulary. In 2026 it is experimental; stabilisation is expected at the end of the year or the start of 2027.&lt;/p>
&lt;h3 id="instrumented-tools-the-2026-landscape">Instrumented tools: the 2026 landscape&lt;/h3>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Tool&lt;/th>
&lt;th>Licence&lt;/th>
&lt;th>Self-host&lt;/th>
&lt;th>Focus&lt;/th>
&lt;th>Where it shines&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>&lt;strong>Langfuse&lt;/strong>&lt;/td>
&lt;td>MIT&lt;/td>
&lt;td>&lt;strong>Yes&lt;/strong>&lt;/td>
&lt;td>LLM observability + evals + prompt mgmt&lt;/td>
&lt;td>Best OSS balance, complete suite&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>LangSmith&lt;/strong>&lt;/td>
&lt;td>Commercial&lt;/td>
&lt;td>No&lt;/td>
&lt;td>Native LangChain/LangGraph&lt;/td>
&lt;td>If you use LangChain, zero-config integration&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Arize Phoenix&lt;/strong>&lt;/td>
&lt;td>ELv2 (OSS)&lt;/td>
&lt;td>Yes&lt;/td>
&lt;td>OTel-native, strong RAG&lt;/td>
&lt;td>Vector DBs, retrieval, embeddings&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Helicone&lt;/strong>&lt;/td>
&lt;td>Commercial + OSS lite&lt;/td>
&lt;td>Yes (lite)&lt;/td>
&lt;td>Simple proxy&lt;/td>
&lt;td>Setup in minutes, OpenAI-only&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>OpenLLMetry / Traceloop&lt;/strong>&lt;/td>
&lt;td>Apache 2.0&lt;/td>
&lt;td>Yes&lt;/td>
&lt;td>OTel SDK for LLMs&lt;/td>
&lt;td>Vendor-neutral, exports to any OTel backend&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Pydantic Logfire&lt;/strong>&lt;/td>
&lt;td>Commercial&lt;/td>
&lt;td>No&lt;/td>
&lt;td>App + LLM unified&lt;/td>
&lt;td>If you use Pydantic AI, native integration&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Weights &amp;amp; Biases Weave&lt;/strong>&lt;/td>
&lt;td>Commercial&lt;/td>
&lt;td>Limited&lt;/td>
&lt;td>Experimentation + production&lt;/td>
&lt;td>If you already use W&amp;amp;B for training&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Laminar / Braintrust&lt;/strong>&lt;/td>
&lt;td>Commercial&lt;/td>
&lt;td>No / Yes&lt;/td>
&lt;td>Evals + tracing&lt;/td>
&lt;td>More recent, focused on evaluation&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;h3 id="deep-dive-langfuse">Deep dive: Langfuse&lt;/h3>
&lt;p>It is worth pausing on &lt;a href="https://langfuse.com/">Langfuse&lt;/a> because in 2026 it is &lt;strong>the default choice among the open-source options&lt;/strong> and the one most teams have adopted this year. It is a &lt;a href="https://github.com/langfuse/langfuse">YC W23&lt;/a> project, &lt;strong>MIT&lt;/strong> licence, and keeps a sustained release cadence with serious architectural changes between versions.&lt;/p>
&lt;p>&lt;strong>Four declared pillars&lt;/strong>: observability (tracing), evaluations, prompt management, playground/datasets. Each one separately has complete commercial products behind it; Langfuse integrates them into a single platform with a single backend.&lt;/p>
&lt;h4 id="sdk-v4-otel-native-not-a-replacement">SDK v4: OTEL-native, not a replacement&lt;/h4>
&lt;p>The big recent operational change is the &lt;strong>SDK v4&lt;/strong>, a thin layer over the official OpenTelemetry client. The choice is deliberate: instead of maintaining a client of its own that would fall behind the OTel primitives, Langfuse uses the standard SDK and &lt;strong>enriches&lt;/strong> the spans with LLM-specific attributes and helpers. The consequence: any code already instrumented with vanilla OpenTelemetry (&lt;code>@opentelemetry/sdk-node&lt;/code>, &lt;code>opentelemetry-sdk&lt;/code> in Python) &lt;strong>can export to Langfuse without major changes&lt;/strong>, and the other way round, if tomorrow you want to migrate from Langfuse to another OTel backend, the spans are portable.&lt;/p>
&lt;p>In Python the idiomatic decorator is &lt;code>@observe&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="kn">from&lt;/span> &lt;span class="nn">langfuse&lt;/span> &lt;span class="kn">import&lt;/span> &lt;span class="n">observe&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">get_client&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="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="nd">@observe&lt;/span>&lt;span class="p">()&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">buscar_documentos&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">query&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="c1"># any internal call is traced as well&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">vector_store&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">similarity_search&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">query&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="nd">@observe&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;generation&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">def&lt;/span> &lt;span class="nf">llamar_llm&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">prompt&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="c1"># marked as &amp;#34;generation&amp;#34; so it appears with LLM metadata&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">openai_client&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">chat&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">completions&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">create&lt;/span>&lt;span class="p">(&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>&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>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="k">def&lt;/span> &lt;span class="nf">pipeline_rag&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">pregunta&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">docs&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">buscar_documentos&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">pregunta&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">llamar_llm&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">build_prompt&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">pregunta&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">docs&lt;/span>&lt;span class="p">))&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>The call tree is captured automatically: the trace shows &lt;code>pipeline_rag&lt;/code> as the root span, with &lt;code>buscar_documentos&lt;/code> and &lt;code>llamar_llm&lt;/code> as children, nested. Without writing a single &lt;code>with tracer.start_as_current_span(...)&lt;/code> by hand.&lt;/p>
&lt;p>In TypeScript the equivalent is modular: you install &lt;code>@langfuse/tracing&lt;/code>, &lt;code>@langfuse/otel&lt;/code> and &lt;code>@opentelemetry/sdk-node&lt;/code>, and you can use TS decorators, context managers or manual spans, the three models interoperate. The consequence: third-party libraries that emit OTel spans (&lt;code>openai&lt;/code>, &lt;code>@anthropic-ai/sdk&lt;/code>, Vercel AI SDK instrumentations) show up in Langfuse with no extra work.&lt;/p>
&lt;h4 id="self-host-architecture-designed-for-serious-production">Self-host architecture: designed for serious production&lt;/h4>
&lt;p>The Langfuse backend architecture has &lt;strong>two explicit decisions&lt;/strong> that set its self-host deployment apart:&lt;/p>
&lt;ol>
&lt;li>
&lt;p>&lt;strong>Persistence in S3/Blob Storage first&lt;/strong>. When a tracing event arrives, &lt;strong>it is persisted in object storage before touching the database&lt;/strong>. Only when the later processing confirms OK is it inserted into Postgres/Clickhouse. If the DB goes down temporarily, the events &lt;strong>are not lost&lt;/strong>; they stay in S3 waiting to be reprocessed. For production where losing the traces of an incident is equivalent to losing evidence, this is load-bearing.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;strong>Long migrations as background jobs&lt;/strong>. Schema upgrades that on other platforms mean a downtime window run in the background on Langfuse while the application keeps serving. Upgrade downtime drops drastically.&lt;/p>
&lt;/li>
&lt;/ol>
&lt;p>The officially supported deployment modes:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Docker Compose&lt;/strong>: for development and POCs. One command, everything up.&lt;/li>
&lt;li>&lt;strong>VM&lt;/strong>: a single node, containers, no orchestration. For small environments.&lt;/li>
&lt;li>&lt;strong>Kubernetes with Helm&lt;/strong>: the recommended mode for production. Official chart maintained. Supports external Postgres, external Clickhouse, external S3, HPA.&lt;/li>
&lt;/ul>
&lt;p>The external dependencies in a typical production setup: &lt;strong>Postgres&lt;/strong> (metadata, prompts, configuration), &lt;strong>Clickhouse&lt;/strong> (tracing events, high-cardinality queries), &lt;strong>S3 or a compatible blob store&lt;/strong> (pending events), &lt;strong>Redis&lt;/strong> (the queue between components). Yes, that is several pieces; it is what holds up the durability and the scale.&lt;/p>
&lt;h4 id="prompt-management-as-a-first-class-citizen">Prompt management as a first-class citizen&lt;/h4>
&lt;p>What sets Langfuse apart from platforms focused only on tracing is that &lt;strong>the prompts live in Langfuse&lt;/strong>, not in the application repo or in spreadsheets. Each prompt has:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Name and version&lt;/strong> (v1, v2, v3&amp;hellip;). Changing the prompt does not require redeploying the app: the app asks the SDK for the prompt, and the SDK caches it and refreshes when there is a new version.&lt;/li>
&lt;li>&lt;strong>Typed variables&lt;/strong>: &lt;code>{{user_input}}&lt;/code>, &lt;code>{{context}}&lt;/code>. Rendering with validation.&lt;/li>
&lt;li>&lt;strong>Tags and labels&lt;/strong>: by environment (&lt;code>production&lt;/code>, &lt;code>staging&lt;/code>), by team, by experiment.&lt;/li>
&lt;li>&lt;strong>Client and server cache&lt;/strong>: the SDK caches locally with a configurable TTL, avoiding a roundtrip to Langfuse on every call.&lt;/li>
&lt;li>&lt;strong>Linkage with traces&lt;/strong>: each trace records which exact version of which prompt was used. Investigating &amp;ldquo;this answer came out wrong&amp;rdquo; leads to prompt version Y, not to &amp;ldquo;some version of the prompt at some point&amp;rdquo;.&lt;/li>
&lt;/ul>
&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>&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="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="n">prompt&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">get_prompt&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;rag-system-prompt&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">version&lt;/span>&lt;span class="o">=&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="c1"># or by label: langfuse.get_prompt(&amp;#34;rag-system-prompt&amp;#34;, label=&amp;#34;production&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">compiled&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">prompt&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">context&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">docs_text&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">user_input&lt;/span>&lt;span class="o">=&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="c1"># &amp;#39;compiled&amp;#39; is the final string, ready to send to the LLM&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>For teams iterating on prompts daily, this is what avoids the chaos of &amp;ldquo;which version of the prompt is actually running in production right now&amp;rdquo;.&lt;/p>
&lt;h4 id="evaluations-four-combinable-evaluation-models">Evaluations: four combinable evaluation models&lt;/h4>
&lt;p>Langfuse covers the four patterns of answer evaluation:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>LLM-as-a-judge&lt;/strong>: you configure a model (typically GPT-4 or Claude) with a rubric and it evaluates each answer. Result: a numeric score (0-1) and a justification. Applicable to automatic tracing (all answers) or batch (a dataset selection).&lt;/li>
&lt;li>&lt;strong>User feedback&lt;/strong>: the app lets the user mark an answer as good/bad. The feedback is associated with the trace and the prompt version, which lets you see which versions have a worse rate.&lt;/li>
&lt;li>&lt;strong>Manual labeling&lt;/strong>: a UI where human labelers score answers. Useful for golden datasets and for evaluating the judge.&lt;/li>
&lt;li>&lt;strong>Custom evaluators through the API/SDK&lt;/strong>: your own evals (a unit test, a business metric) report a score through the API. They integrate with CI.&lt;/li>
&lt;/ul>
&lt;p>Combined, they give &lt;strong>regression testing&lt;/strong> of the prompt: you change from v3 to v4, evaluate the golden dataset with LLM-as-judge, compare; if v4 gets worse in any of the segments, the merge fails.&lt;/p>
&lt;h4 id="integrations">Integrations&lt;/h4>
&lt;p>Langfuse does not compete with OpenLLMetry, LangChain or LiteLLM: it &lt;strong>integrates&lt;/strong> them. The ones that are tested and documented:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>OpenTelemetry&lt;/strong>: any OTel instrumentation emits to Langfuse over OTLP.&lt;/li>
&lt;li>&lt;strong>LangChain and LangGraph&lt;/strong>: a native callback that captures the whole chain.&lt;/li>
&lt;li>&lt;strong>LlamaIndex&lt;/strong>: native callback.&lt;/li>
&lt;li>&lt;strong>OpenAI SDK&lt;/strong> (Python and TS): a wrapper that adds tracing automatically.&lt;/li>
&lt;li>&lt;strong>LiteLLM&lt;/strong>: integration as a callback, which covers 100+ providers through LiteLLM.&lt;/li>
&lt;li>&lt;strong>OpenLLMetry / Traceloop&lt;/strong>: they emit to Langfuse like any OTel backend.&lt;/li>
&lt;li>&lt;strong>MLflow&lt;/strong>: through an OTel exporter from MLflow to Langfuse.&lt;/li>
&lt;li>&lt;strong>Vercel AI SDK&lt;/strong>: native instrumentation.&lt;/li>
&lt;/ul>
&lt;p>The strategy is clear: &lt;strong>Langfuse is a backend, not an SDK&lt;/strong>. Your team chooses how it instruments; Langfuse accepts any path. The operational consequence: moving from Langfuse to another OTel backend tomorrow is viable.&lt;/p>
&lt;h4 id="when-langfuse-is-not-the-answer">When Langfuse is not the answer&lt;/h4>
&lt;p>So as not to present it as a silver bullet:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>If you only use LangChain and have no resources to self-host&lt;/strong>: LangSmith will give you a smoother integration (it is the same team).&lt;/li>
&lt;li>&lt;strong>If your only need is a proxy with cost tracking and no evals&lt;/strong>: Helicone is simpler.&lt;/li>
&lt;li>&lt;strong>If you want an integrated commercial vendor solution&lt;/strong>: Datadog LLM Observability, New Relic AI Monitoring or Dynatrace AI are Enterprise alternatives with 24/7 support.&lt;/li>
&lt;li>&lt;strong>If your load is pure batch massive inference with no agents&lt;/strong>: you probably do not need semantic tracing; Prometheus + Grafana with OTel metrics is enough.&lt;/li>
&lt;/ul>
&lt;p>For everything else, your own apps with serious tracing, multi-tenant with quotas, teams iterating on prompts daily, RAG with continuous evaluation, Langfuse is the safe bet.&lt;/p>
&lt;p>&lt;strong>Quick choice summary&lt;/strong>:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>LangChain → LangSmith&lt;/strong> (zero effort, automatic instrumentation).&lt;/li>
&lt;li>&lt;strong>Your own multi-framework applications with OSS → Langfuse&lt;/strong> (MIT, self-host, complete).&lt;/li>
&lt;li>&lt;strong>RAG with vector stores → Arize Phoenix&lt;/strong> (better retrieval visibility).&lt;/li>
&lt;li>&lt;strong>Simple proxy, low budget → Helicone&lt;/strong>.&lt;/li>
&lt;li>&lt;strong>Strict vendor neutrality → OpenLLMetry/Traceloop&lt;/strong>.&lt;/li>
&lt;li>&lt;strong>Pydantic AI → Logfire&lt;/strong> (same team).&lt;/li>
&lt;/ul>
&lt;h3 id="strengths-and-weaknesses-of-the-instrumented-model">Strengths and weaknesses of the instrumented model&lt;/h3>
&lt;p>&lt;strong>Strengths&lt;/strong>:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Enormous depth&lt;/strong>: nested spans with all the context (chain steps, retrieval, embeddings, tool calls).&lt;/li>
&lt;li>&lt;strong>Semantic vocabulary&lt;/strong>: the SDK knows the domain (LLM, vector store, agent).&lt;/li>
&lt;li>&lt;strong>Maturity&lt;/strong>: three years of evolution, a rich ecosystem, ready-made dashboards.&lt;/li>
&lt;li>&lt;strong>Built-in evals&lt;/strong>: the top platforms combine tracing with evaluation (judge LLM, datasets, regression).&lt;/li>
&lt;/ul>
&lt;p>&lt;strong>Weaknesses&lt;/strong>:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Requires control of the code&lt;/strong>: if you cannot instrument, it does not work.&lt;/li>
&lt;li>&lt;strong>Trust in the app&lt;/strong>: if the app reports badly or has a bug, so does the trace. It is not tamper-proof.&lt;/li>
&lt;li>&lt;strong>Coupling to the SDK&lt;/strong>: version changes in a library can break the instrumentation.&lt;/li>
&lt;li>&lt;strong>Uneven coverage&lt;/strong>: Python SDKs are mature; Go, Rust and JS are younger.&lt;/li>
&lt;/ul>
&lt;h2 id="the-zero-instrumentation-approach-agentsight">The zero-instrumentation approach: AgentSight&lt;/h2>
&lt;p>&lt;a href="https://github.com/eunomia-bpf/agentsight">AgentSight&lt;/a> is the project from the &lt;code>eunomia-bpf&lt;/code> group that champions the opposite approach. Its &lt;a href="https://arxiv.org/abs/2508.02736">paper on arxiv (2508.02736)&lt;/a>, presented at the &lt;em>Workshop on Practical Adoption Challenges of ML for Systems&lt;/em>, formalises the proposal. The premise is direct:&lt;/p>
&lt;blockquote>
&lt;p>&lt;em>Instead of instrumenting the agent, observe it at the system boundary.&lt;/em>&lt;/p>
&lt;/blockquote>
&lt;p>And &amp;ldquo;system boundary&amp;rdquo; means &lt;strong>the kernel boundary&lt;/strong>: the last point before a piece of data leaves the process towards the network or the filesystem. There, with eBPF, things are seen as they are, without the application being able to cooperate in hiding them.&lt;/p>
&lt;h3 id="architecture-three-planes">Architecture: three planes&lt;/h3>
&lt;p>AgentSight assembles three layers:&lt;/p>
&lt;p>&lt;strong>Plane 1 — SSL/TLS uprobes&lt;/strong>. eBPF can attach programs to functions in &lt;strong>userspace libraries&lt;/strong> (uprobes). The target functions are the encryption ones: &lt;code>SSL_write&lt;/code>, &lt;code>SSL_read&lt;/code> from OpenSSL/BoringSSL, and their equivalents in Rustls. AgentSight puts hooks on them that &lt;strong>capture the arguments&lt;/strong>: the &lt;strong>plaintext&lt;/strong> buffer the app passes to be encrypted, just before TLS processes it. On reception it does the symmetric thing: a hook after &lt;code>SSL_read&lt;/code> with the freshly decrypted plaintext. Result: AgentSight sees the complete content of any HTTPS request the app makes &lt;strong>without needing a man-in-the-middle, certificates or traffic decryption&lt;/strong>. The payload is plaintext because it was captured &lt;strong>before&lt;/strong> being encrypted.&lt;/p>
&lt;p>This works because uprobes are cheap (around 100 ns per invocation) and because apps use common TLS libraries. The few apps that implement their own TLS (rare in production) escape this hook; for those you need a different kprobe or manual instrumentation.&lt;/p>
&lt;p>&lt;strong>Plane 2 — Kernel events&lt;/strong>. In parallel, AgentSight observes relevant syscalls through tracepoints: &lt;code>execve&lt;/code> (which processes start), &lt;code>connect&lt;/code>/&lt;code>accept&lt;/code> (network), &lt;code>read&lt;/code>/&lt;code>write&lt;/code> with file descriptors (filesystem and stdio), &lt;code>unlink&lt;/code>, &lt;code>clone&lt;/code>. Any action by the agent that has an effect outside the process passes through here. This covers, among other things, &lt;strong>shell commands executed by the agent&lt;/strong>: if a Claude Code agent decides to run &lt;code>rm -rf&lt;/code> to &amp;ldquo;clean up the project&amp;rdquo;, the &lt;code>execve&lt;/code> is seen even if the LLM API does not report it.&lt;/p>
&lt;p>&lt;strong>Plane 3 — Correlation engine&lt;/strong>. The two previous planes produce streams of asynchronous events. AgentSight has a userspace component that &lt;strong>correlates them causally cross-process&lt;/strong>: an outgoing HTTP request with &lt;code>bash -c rm -rf&lt;/code> can be correlated with the previous LLM response that suggested it, through PIDs, timings and heuristics. The paper mentions the optional use of &lt;strong>a secondary LLM&lt;/strong> (Anthropic Claude, for instance) that analyses the sequence of events and produces semantic alerts: &amp;ldquo;the agent responded with a tool call that was not in the whitelist&amp;rdquo;, &amp;ldquo;the reasoning chain has gone 47 iterations without converging&amp;rdquo;.&lt;/p>
&lt;h3 id="stdiocap-capturing-the-stdio-of-local-mcp-servers">&lt;code>stdiocap&lt;/code>: capturing the stdio of local MCP servers&lt;/h3>
&lt;p>One specific piece that deserves its own mention is &lt;code>stdiocap&lt;/code>, a separate BPF tool included in the repo. The &lt;strong>Model Context Protocol (MCP)&lt;/strong>, popularised by Anthropic in 2024 and mainstream in 2025-2026, has two transport modes: HTTP/SSE (network) and &lt;strong>stdio&lt;/strong> (between the client and the server it starts as a subprocess). Local MCP servers, the ones that run on the same machine and are started by the client as children over pipes, communicate through stdin/stdout/stderr with JSON-RPC.&lt;/p>
&lt;p>&lt;code>stdiocap&lt;/code> hooks &lt;code>read&lt;/code>/&lt;code>write&lt;/code>/&lt;code>dup&lt;/code> on the stdin/stdout/stderr file descriptors of a target process and &lt;strong>records all the JSON-RPC traffic&lt;/strong> between the MCP client and server. It is the same idea as the SSL capture but for stdio: you observe the conversation without either the client or the server knowing. Typical use case: seeing which tools of the &lt;code>filesystem-mcp&lt;/code> MCP server a Claude Code agent has invoked in the last hour, what arguments it passed, what errors it received. Impossible with classic instrumentation (MCP servers are usually third-party binaries).&lt;/p>
&lt;h3 id="guarantees-tamper-proof-kernel-safety-3-overhead">Guarantees: tamper-proof, kernel safety, &amp;lt;3% overhead&lt;/h3>
&lt;p>Three properties make AgentSight interesting for production:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Tamper-proof&lt;/strong>: the observation happens in the kernel (uprobes, syscalls). A malicious or compromised application cannot falsify what is seen. Compare with instrumentation: if the agent decides not to emit the span for its action, it does not appear in Langfuse. Here it has no choice.&lt;/li>
&lt;li>&lt;strong>Kernel safety&lt;/strong>: eBPF formally verifies that programs terminate and respect bounds checks. It cannot crash the kernel. The same as in the rest of the eBPF series.&lt;/li>
&lt;li>&lt;strong>&amp;lt;3% CPU overhead&lt;/strong> measured on real agent workloads (per the paper). The number compares favourably with SDK instrumentation, which typically adds 5-10% in intensive applications.&lt;/li>
&lt;/ul>
&lt;h3 id="what-it-detects-out-of-the-box">What it detects out of the box&lt;/h3>
&lt;p>The paper and the documentation highlight three classes of detection:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Real-time prompt injection&lt;/strong>: the correlation engine can apply rules or a detection model over the plaintext captured by the SSL uprobes. If the prompt contains suspicious patterns, &amp;ldquo;ignore all previous instructions&amp;rdquo;, a system prompt embedded in a user input, instructions to exfiltrate data, it raises an alert.&lt;/li>
&lt;li>&lt;strong>Reasoning loops that burn resources&lt;/strong>: agents that enter infinite loops calling tools without progressing. Detectable because the causal chain does not converge to a &amp;ldquo;final answer&amp;rdquo; and the tokens pile up. The correlation engine flags them.&lt;/li>
&lt;li>&lt;strong>Bottlenecks in multi-agent setups&lt;/strong>: when several agents coordinate, AgentSight sees the communication matrix between all of them and can detect agents that block while waiting, deadlocks, excessive fan-out.&lt;/li>
&lt;/ul>
&lt;h2 id="the-clash-and-the-coexistence">The clash and the coexistence&lt;/h2>
&lt;p>The two families look like competitors, but in reality they see different things and complement each other in production.&lt;/p>
&lt;h3 id="what-only-the-instrumented-one-sees">What only the instrumented one sees&lt;/h3>
&lt;ul>
&lt;li>&lt;strong>Internal agent variables&lt;/strong> that never reach the wire: the intermediate state of a LangChain chain, the values before passing them to a tool, how a prompt is built from a template with internal vars.&lt;/li>
&lt;li>&lt;strong>Deep semantic spans&lt;/strong>: &lt;code>retrieval &amp;gt; embed &amp;gt; vector_search &amp;gt; rerank &amp;gt; format_context &amp;gt; prompt_template &amp;gt; llm&lt;/code>. AgentSight sees only the final call to the LLM; the path taken to build it is invisible.&lt;/li>
&lt;li>&lt;strong>Evaluations&lt;/strong>: scoring of answers, judge LLMs, quality regression. This lives only in instrumented platforms.&lt;/li>
&lt;/ul>
&lt;h3 id="what-only-ebpf-sees">What only eBPF sees&lt;/h3>
&lt;ul>
&lt;li>&lt;strong>Opaque binaries&lt;/strong>: Claude Code, Cursor, Gemini CLI, third-party agents. You do not have the code; you cannot instrument them. Only eBPF sees them.&lt;/li>
&lt;li>&lt;strong>System-level actions&lt;/strong>: the agent decides to run &lt;code>git push --force&lt;/code> or &lt;code>kubectl delete&lt;/code>. The action is seen in the &lt;code>execve&lt;/code>. The agent&amp;rsquo;s instrumentation may not report it (especially if it was a command the agent generated as output without going through an explicit &amp;ldquo;tool&amp;rdquo;).&lt;/li>
&lt;li>&lt;strong>Tamper-proof audit&lt;/strong>: for regulatory compliance (HIPAA, SOC2, NIS2), having observation the app cannot dodge has formal value. eBPF provides it.&lt;/li>
&lt;li>&lt;strong>Local MCP servers over stdio&lt;/strong>: invisible to classic instrumentation unless each server emits its own spans (rare).&lt;/li>
&lt;/ul>
&lt;h3 id="what-both-see-complementarily">What both see, complementarily&lt;/h3>
&lt;ul>
&lt;li>&lt;strong>Prompts and completions&lt;/strong>: the instrumented approach emits them with rich metadata; eBPF captures them off the wire. A perfect cross-check for spotting discrepancies.&lt;/li>
&lt;li>&lt;strong>Calls to external APIs&lt;/strong>: APM flags it; eBPF confirms it at kernel level.&lt;/li>
&lt;li>&lt;strong>Latency&lt;/strong>: APM per span; eBPF measures RTT at TCP level and network connectivity.&lt;/li>
&lt;/ul>
&lt;h3 id="decision-matrix">Decision matrix&lt;/h3>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Case&lt;/th>
&lt;th>Instrumented&lt;/th>
&lt;th>eBPF (AgentSight)&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Your own app with LangChain&lt;/td>
&lt;td>&lt;strong>Yes, first&lt;/strong>&lt;/td>
&lt;td>Optional&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Your own multi-framework app&lt;/td>
&lt;td>&lt;strong>Yes&lt;/strong>&lt;/td>
&lt;td>Optional&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Third-party binary (Claude Code, Cursor)&lt;/td>
&lt;td>&lt;strong>Does not work&lt;/strong>&lt;/td>
&lt;td>&lt;strong>Yes, the only path&lt;/strong>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Tamper-proof regulatory compliance&lt;/td>
&lt;td>Insufficient&lt;/td>
&lt;td>&lt;strong>Yes, required&lt;/strong>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Zero-trust multi-tenant&lt;/td>
&lt;td>Insufficient&lt;/td>
&lt;td>&lt;strong>Yes, required&lt;/strong>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Local MCP servers (stdio)&lt;/td>
&lt;td>Difficult&lt;/td>
&lt;td>&lt;strong>Yes, with stdiocap&lt;/strong>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Answer quality evaluation&lt;/td>
&lt;td>&lt;strong>Yes, required&lt;/strong>&lt;/td>
&lt;td>No (out of scope)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Depth of the internal chain&lt;/td>
&lt;td>&lt;strong>Yes, required&lt;/strong>&lt;/td>
&lt;td>No (a black box for AgentSight)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Reasoning loop detection&lt;/td>
&lt;td>Possible with plumbing&lt;/td>
&lt;td>&lt;strong>Yes, built in&lt;/strong>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Real-time prompt injection&lt;/td>
&lt;td>Possible (post-processed)&lt;/td>
&lt;td>&lt;strong>Yes, in stream&lt;/strong>&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>The natural conclusion: &lt;strong>for your own apps, instrumented; for opaque binaries or compliance, eBPF; for everything that matters, both&lt;/strong>.&lt;/p>
&lt;h2 id="2026-reference-architecture">2026 reference architecture&lt;/h2>
&lt;p>Four recipes that cover the bulk of real cases:&lt;/p>
&lt;h3 id="setup-a--your-own-application-with-langchain-or-similar">Setup A — Your own application with LangChain or similar&lt;/h3>
&lt;p>Needs: depth, evals, a team comfortable with SDKs.&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Langfuse self-host&lt;/strong> or &lt;strong>LangSmith cloud&lt;/strong> as the backend.&lt;/li>
&lt;li>&lt;strong>OpenLLMetry SDK&lt;/strong> or &lt;strong>LangSmith SDK&lt;/strong> instrumenting the code.&lt;/li>
&lt;li>&lt;strong>OpenTelemetry Collector&lt;/strong> between the app and the backend for routing flexibility (to Langfuse + Tempo + Loki, for instance).&lt;/li>
&lt;li>&lt;strong>Hubble&lt;/strong> for the network layer in the cluster (inter-pod latency, drop attribution).&lt;/li>
&lt;/ul>
&lt;h3 id="setup-b--productionising-an-opaque-binary-claude-code-gemini-cli">Setup B — Productionising an opaque binary (Claude Code, Gemini CLI)&lt;/h3>
&lt;p>Needs: observe without touching, audit, control cost.&lt;/p>
&lt;ul>
&lt;li>&lt;strong>AgentSight&lt;/strong> deployed as a DaemonSet over the cluster (or standalone on the node).&lt;/li>
&lt;li>&lt;strong>Grafana with dashboards&lt;/strong> fed by AgentSight&amp;rsquo;s metrics.&lt;/li>
&lt;li>&lt;strong>An OTLP exporter&lt;/strong> from AgentSight to an OTel backend (Tempo, Jaeger). The spans will use the GenAI semantic conventions once they are fully standardised.&lt;/li>
&lt;li>&lt;strong>Tetragon&lt;/strong> optionally for policy over what the agent is allowed to run (Sigkill if it tries &lt;code>rm -rf&lt;/code> or similar).&lt;/li>
&lt;/ul>
&lt;h3 id="setup-c--zero-trust-multi-tenant-platform">Setup C — Zero-trust multi-tenant platform&lt;/h3>
&lt;p>Needs: agents from different clients running on the same cluster, mandatory audit, nobody trusts anybody.&lt;/p>
&lt;ul>
&lt;li>&lt;strong>AgentSight&lt;/strong> as the tamper-proof audit layer. Compliance requires it.&lt;/li>
&lt;li>&lt;strong>Langfuse multi-tenant&lt;/strong> for the clients that do instrument.&lt;/li>
&lt;li>&lt;strong>Tetragon&lt;/strong> with &lt;code>TracingPolicyNamespaced&lt;/code> per tenant (different policies per namespace).&lt;/li>
&lt;li>&lt;strong>Hubble&lt;/strong> with persistent flow logs for forensics.&lt;/li>
&lt;li>&lt;strong>Cilium NetworkPolicy&lt;/strong> to isolate tenants from each other on the network.&lt;/li>
&lt;/ul>
&lt;h3 id="setup-d--a-local-mcp-server-on-a-workstation">Setup D — A local MCP server on a workstation&lt;/h3>
&lt;p>Needs: seeing what an agent does with a stdio MCP server.&lt;/p>
&lt;ul>
&lt;li>&lt;strong>AgentSight stdiocap&lt;/strong> pointed at the PID of the client or the server.&lt;/li>
&lt;li>Complete JSON-RPC capture to a file or to an OTLP endpoint.&lt;/li>
&lt;li>Visualisation: Grafana, or simply &lt;code>jq&lt;/code> over the log.&lt;/li>
&lt;/ul>
&lt;p>A real use case: if you are integrating an MCP server of your own and want to see what tool calls a Claude Code or Cursor agent makes to your server, &lt;code>stdiocap&lt;/code> is the cleanest way. You need to modify neither client nor server.&lt;/p>
&lt;h2 id="operational-traps">Operational traps&lt;/h2>
&lt;h3 id="sensitive-data-in-prompts-instrumented">Sensitive data in prompts (instrumented)&lt;/h3>
&lt;p>By default, Langfuse, LangSmith and similar tools capture &lt;strong>the complete content&lt;/strong> of prompts and completions. If your app processes PII, secrets or medical data, &lt;strong>that goes to your observability backend&lt;/strong>. Configuring &lt;strong>redaction&lt;/strong> or &lt;strong>content opt-out&lt;/strong> before going to production is mandatory. OTel GenAI has specific flags (&lt;code>OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT=false&lt;/code>) to avoid it.&lt;/p>
&lt;h3 id="sensitive-data-in-prompts-agentsight">Sensitive data in prompts (AgentSight)&lt;/h3>
&lt;p>The same problem, worse: AgentSight captures &lt;strong>literally what goes to the wire&lt;/strong>, in plaintext. If the agent talked to &lt;code>api.openai.com&lt;/code> with a prompt containing sensitive data, AgentSight has that plaintext. You have to encrypt or redact before storing.&lt;/p>
&lt;h3 id="pinned-certificates-or-non-standard-tls">Pinned certificates or non-standard TLS&lt;/h3>
&lt;p>Some high-security apps do certificate pinning or use unconventional TLS implementations (Go&amp;rsquo;s &lt;code>crypto/tls&lt;/code>, custom BoringSSL). In those cases, the uprobes on &lt;code>libssl&lt;/code> do not cover them. AgentSight detects when it cannot observe and reports the gap; you still have to add specific hooks for the alternative SDK.&lt;/p>
&lt;h3 id="token-volume-and-storage">Token volume and storage&lt;/h3>
&lt;p>An application with medium traffic can generate &lt;strong>millions of tokens a day&lt;/strong>. If you store them all in Langfuse or Phoenix with long retention, the database grows fast. Strategies: aggressive sampling, short retention for normal sessions and long retention only for errors/anomalies, content redaction and keeping only metadata.&lt;/p>
&lt;h3 id="tracing-with-sampling-and-consistency">Tracing with sampling and consistency&lt;/h3>
&lt;p>To cut cost, many installations sample: only 1 in every N traces is persisted. &lt;strong>Be careful with inconsistent sampling&lt;/strong>: a trace can carry several spans across multiple services, and if the sampling decision is taken per span, you end up with incomplete traces. OTel has &lt;strong>head sampling&lt;/strong> (in the SDK, at the start), which is consistent, and &lt;strong>tail sampling&lt;/strong> (in the collector, at the end), which allows finer rules. For LLM, tail sampling is ideal: sample everything, discard only the &amp;ldquo;normal&amp;rdquo; traces and keep the ones with errors, high latency or high cost.&lt;/p>
&lt;h3 id="multi-agent-and-trace-propagation">Multi-agent and trace propagation&lt;/h3>
&lt;p>When agent A calls agent B, you have to &lt;strong>propagate the trace context&lt;/strong> (W3C Trace Context headers) so it shows up as a single tree. If you do not, you see two disconnected traces. Modern platforms do it automatically with &lt;code>inject&lt;/code>/&lt;code>extract&lt;/code>, but if your transport between agents is custom (over Redis pub/sub, over a DB), you have to propagate by hand.&lt;/p>
&lt;h3 id="the-cost-of-uprobes-on-critical-libraries">The cost of uprobes on critical libraries&lt;/h3>
&lt;p>Hooking &lt;code>libssl&lt;/code> adds around 100 ns per invocation. On extreme TLS traffic loads (tens of thousands of connections/s per core), that adds up. AgentSight keeps it under 3% on typical agent workloads (which are chatty but not networking-intensive). If your use were sniffing all the HTTPS on the node, it could hurt more.&lt;/p>
&lt;h2 id="what-we-have-not-covered-next-series">What we have not covered (next series)&lt;/h2>
&lt;ul>
&lt;li>&lt;strong>Evals&lt;/strong>: the next layer after tracing. Phoenix, Langfuse, LangSmith and company offer answer evaluation (judge LLM, datasets, regression). It is a world of its own.&lt;/li>
&lt;li>&lt;strong>Guardrails and safety&lt;/strong>: NeMo Guardrails, Llama Guard, Llama Prompt Guard, evaluators specific to prompt injection and jailbreaks.&lt;/li>
&lt;li>&lt;strong>Deep MCP server observability&lt;/strong>: how the OpenTelemetry GenAI conventions are being extended to MCP servers for trace-aware tools.&lt;/li>
&lt;li>&lt;strong>eBPF + on-device inference&lt;/strong>: when the LLM runs locally through vLLM or llama.cpp, the uprobes can see the output token queue BEFORE it goes to the client. New territory.&lt;/li>
&lt;li>&lt;strong>Statistical analysis of agent flows&lt;/strong>: detecting drift, outliers, patterns that indicate degradation.&lt;/li>
&lt;/ul>
&lt;h2 id="closing-the-ebpf-series">Closing the eBPF series&lt;/h2>
&lt;p>This series of four articles has travelled eBPF from first principles to the 2026 frontier:&lt;/p>
&lt;ol>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/ebpf-zero-to-cilium-kernel-tcp-ip-bypass/">eBPF from zero to Cilium&lt;/a> — what eBPF is, networking hooks, how Cilium skips the TCP/IP stack, BGP Control Plane v2.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/tetragon-cilium-security-syscalls-kernel/">Tetragon: runtime security&lt;/a> — observability and enforcement of processes in the kernel.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/hubble-ebpf-network-observability-ai-agents/">Hubble: network observability&lt;/a> — L3-L7 flow logs and the frontier with AI agents.&lt;/li>
&lt;li>&lt;strong>This one&lt;/strong> — AgentSight, LLM tracing, instrumented vs zero-instrumentation.&lt;/li>
&lt;/ol>
&lt;p>If you have got this far you have the map to sit down with a platform, security or AI team in 2026 and recognise what each piece does, what problem it solves and where to start. That whole stack, Cilium for CNI and BGP, Tetragon for runtime security, Hubble for network observability, AgentSight for AI agents, sharing eBPF as a common substrate, Cloud Native governance and OpenTelemetry vocabulary. It is the clean architecture the industry asked for a decade ago and that finally exists.&lt;/p>
&lt;h2 id="references">References&lt;/h2>
&lt;p>AgentSight:&lt;/p>
&lt;ul>
&lt;li>&lt;a href="https://github.com/eunomia-bpf/agentsight">AgentSight GitHub (eunomia-bpf)&lt;/a> — the project.&lt;/li>
&lt;li>&lt;a href="https://arxiv.org/abs/2508.02736">AgentSight: System-Level Observability for AI Agents Using eBPF (arxiv 2508.02736)&lt;/a> — the formal paper.&lt;/li>
&lt;li>&lt;a href="https://dl.acm.org/doi/10.1145/3766882.3767169">AgentSight ACM workshop publication&lt;/a>.&lt;/li>
&lt;li>&lt;a href="https://eunomia.dev/blog/2025/08/26/agentsight-keeping-your-ai-agents-under-control-with-ebpf-powered-system-observability/">AgentSight blog post (eunomia.dev)&lt;/a> — accessible description.&lt;/li>
&lt;/ul>
&lt;p>OpenTelemetry GenAI semantic conventions:&lt;/p>
&lt;ul>
&lt;li>&lt;a href="https://opentelemetry.io/docs/specs/semconv/gen-ai/">OpenTelemetry — Semantic conventions for generative AI systems&lt;/a> — official reference.&lt;/li>
&lt;li>&lt;a href="https://opentelemetry.io/docs/specs/semconv/gen-ai/gen-ai-spans/">Semantic conventions for generative client AI spans&lt;/a>.&lt;/li>
&lt;li>&lt;a href="https://opentelemetry.io/docs/specs/semconv/gen-ai/gen-ai-metrics/">Semantic conventions for generative AI metrics&lt;/a>.&lt;/li>
&lt;li>&lt;a href="https://opentelemetry.io/blog/2026/genai-observability/">Inside the LLM Call: GenAI Observability with OpenTelemetry (OTel blog 2026)&lt;/a>.&lt;/li>
&lt;li>&lt;a href="https://github.com/open-telemetry/semantic-conventions/issues/2664">Multi-agent Semantic Conventions (GitHub issue #2664)&lt;/a>.&lt;/li>
&lt;/ul>
&lt;p>Instrumented platforms:&lt;/p>
&lt;ul>
&lt;li>&lt;a href="https://langfuse.com/">Langfuse&lt;/a> — MIT, self-host + cloud.&lt;/li>
&lt;li>&lt;a href="https://www.langchain.com/langsmith">LangSmith&lt;/a> — LangChain team.&lt;/li>
&lt;li>&lt;a href="https://phoenix.arize.com/">Arize Phoenix&lt;/a> — OSS, OTel-native.&lt;/li>
&lt;li>&lt;a href="https://www.helicone.ai/">Helicone&lt;/a> — simple proxy.&lt;/li>
&lt;li>&lt;a href="https://github.com/traceloop/openllmetry">OpenLLMetry (Traceloop)&lt;/a> — Apache 2.0, OTel SDK.&lt;/li>
&lt;li>&lt;a href="https://pydantic.dev/docs/logfire/get-started/ai-observability/">Pydantic Logfire — AI observability&lt;/a>.&lt;/li>
&lt;/ul>
&lt;p>2026 comparisons:&lt;/p>
&lt;ul>
&lt;li>&lt;a href="https://www.braintrust.dev/articles/langfuse-alternatives-2026">Langfuse alternatives 2026 (Braintrust)&lt;/a>.&lt;/li>
&lt;li>&lt;a href="https://www.braintrust.dev/articles/best-llm-tracing-tools-2026">7 best LLM tracing tools for multi-agent AI systems (2026)&lt;/a>.&lt;/li>
&lt;li>&lt;a href="https://medium.com/@kanerika/llmops-observability-langsmith-vs-arize-vs-langfuse-vs-w-b-f1baeabd1bbf">LLMOps Observability: LangSmith vs Arize vs Langfuse vs W&amp;amp;B&lt;/a>.&lt;/li>
&lt;li>&lt;a href="https://www.firecrawl.dev/blog/best-llm-observability-tools">Best LLM Observability Tools in 2026 (Firecrawl)&lt;/a>.&lt;/li>
&lt;li>&lt;a href="https://www.spheron.network/blog/llm-observability-gpu-cloud-langfuse-arize-phoenix-helicone/">LLM Observability on GPU Cloud (Spheron 2026 guide)&lt;/a>.&lt;/li>
&lt;/ul>
&lt;p>Cross-references from the series:&lt;/p>
&lt;ul>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/ebpf-zero-to-cilium-kernel-tcp-ip-bypass/">eBPF from zero to Cilium&lt;/a>.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/tetragon-cilium-security-syscalls-kernel/">Tetragon: runtime security&lt;/a>.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/hubble-ebpf-network-observability-ai-agents/">Hubble: network observability&lt;/a>.&lt;/li>
&lt;li>LLM inference series: &lt;a href="https://blog.lo0.es/en/posts/kv-cache-working-memory-llm-inference/">KV cache&lt;/a>, &lt;a href="https://blog.lo0.es/en/posts/vllm-kubernetes-llm-inference-that-scales/">vLLM on Kubernetes&lt;/a>, &lt;a href="https://blog.lo0.es/en/posts/pagedattention-vllm-block-manager/">PagedAttention&lt;/a>, &lt;a href="https://blog.lo0.es/en/posts/llm-inference-operators-kubernetes/">LLM Operators on K8s&lt;/a>.&lt;/li>
&lt;/ul></description></item><item><title>Hubble: eBPF network observability, state of the art 2026 and the new frontier with AI agents</title><link>https://blog.lo0.es/en/posts/hubble-ebpf-network-observability-ai-agents/</link><pubDate>Tue, 19 May 2026 06:00:00 +0200</pubDate><guid>https://blog.lo0.es/en/posts/hubble-ebpf-network-observability-ai-agents/</guid><description>&lt;h2 id="tldr">TL;DR&lt;/h2>
&lt;p>&lt;a href="https://github.com/cilium/hubble">Hubble&lt;/a> is &lt;strong>Cilium&amp;rsquo;s native network observability&lt;/strong>, built on the same eBPF programs Cilium uses for enforcement. It does not duplicate the datapath or instrument the kernel its own way: it &lt;strong>listens&lt;/strong> to the hooks Cilium already has and produces structured flow logs with Kubernetes context included, covering pod, namespace, labels, service, policy verdict and L7 payload where applicable. It is what happens when someone decides that &lt;code>tcpdump&lt;/code> with &lt;code>grep&lt;/code> does not scale to 10,000 pods and builds a distributed system of their own (a Hubble server per node, plus Hubble Relay as the aggregator, plus a CLI and a UI) with practically zero overhead, because the capture was already happening. In 2026 it is at version 1.19.3 (April 2026), with Cilium 1.19 marking the project&amp;rsquo;s tenth anniversary; IP options tracing has arrived, along with filtering by encryption status, the drop event tagged with the exact NetworkPolicy that caused it (direct attribution), a stabilised field mask API, and the first wave of ML anomaly detection applied to flows for predictive security in IoT/5G clusters. And, most interesting of all for 2026: a new frontier appears where the same eBPF observes AI agents such as Claude Code, Gemini CLI and MCP agents, intercepting SSL/TLS and stdio without instrumenting the code, which turns the Cilium + Hubble + Tetragon + AgentSight stack into a complete set of tools for understanding what an agentic system does inside a cluster.&lt;/p>
&lt;blockquote>
&lt;p>This article is part 3 of the eBPF series. Part 1: &lt;a href="https://blog.lo0.es/en/posts/ebpf-zero-to-cilium-kernel-tcp-ip-bypass/">eBPF from zero to Cilium: how the kernel learned to skip its own TCP/IP stack&lt;/a>. Part 2: &lt;a href="https://blog.lo0.es/en/posts/tetragon-cilium-security-syscalls-kernel/">Tetragon: Cilium&amp;rsquo;s security cousin that sees every syscall in the kernel&lt;/a>. Here we complete the observability quadrant: network with Hubble, process with Tetragon, AI agent with AgentSight.&lt;/p>
&lt;/blockquote>
&lt;h2 id="the-analogy-tcpdump-that-speaks-kubernetes">The analogy: tcpdump that speaks Kubernetes&lt;/h2>
&lt;p>If you have administered networks over the last twenty years, &lt;code>tcpdump&lt;/code> and Wireshark have been your daily bread. They capture packets on an interface, parse them, and let you filter with &lt;code>tcp.port == 443 and host 10.0.0.5&lt;/code>. They work, they have been working since the 90s, and they are the first thing you open when something smells wrong.&lt;/p>
&lt;p>Now stick &lt;code>tcpdump&lt;/code> on a Kubernetes cluster with 10,000 pods. The problems show up in order:&lt;/p>
&lt;ol>
&lt;li>&lt;strong>One &lt;code>tcpdump&lt;/code> session per node&lt;/strong>. You wanted to &amp;ldquo;see the traffic between the frontend and the API&amp;rdquo;; you need SSH to every node, tcpdump on every NIC, synchronised timestamps, manual aggregation.&lt;/li>
&lt;li>&lt;strong>There is no K8s context&lt;/strong>. You see a packet from &lt;code>10.244.5.7&lt;/code> to &lt;code>10.244.8.42&lt;/code>. Which pod was that? Which namespace? Which label? You have to correlate with &lt;code>kubectl get pod -A -o wide&lt;/code> every single time.&lt;/li>
&lt;li>&lt;strong>No L7 understanding&lt;/strong>. You see a POST to HTTPS, and you cannot know the method and path because it is encrypted on the wire. If there is mTLS between pods, worse.&lt;/li>
&lt;li>&lt;strong>High cost&lt;/strong>: full packet capture with a copy to userspace slows down the datapath. Under dense traffic, you notice it.&lt;/li>
&lt;/ol>
&lt;p>Hubble is &lt;strong>tcpdump redesigned for all of that&lt;/strong>. It reuses the eBPF programs that are already processing every packet (Cilium puts them there for enforcement) and, while they take their allow/deny decision, they emit a flow event with all the context: source and destination pod identity, namespace, labels, protocol, verdict, and, if Cilium has done L7 parsing via Envoy, the HTTP method, path, status code, DNS query, Kafka topic. That event travels through a ringbuffer to userspace, it is received by the Hubble server that lives inside the node&amp;rsquo;s Cilium agent, and it is exposed over gRPC. A separate service, Hubble Relay, aggregates the streams from all nodes and gives you a single cluster-wide API. On top of that: a CLI (&lt;code>hubble&lt;/code>) and a web UI with a real-time service graph.&lt;/p>
&lt;p>Zero additional copies. Zero duplicated parsing. And the result is understandable by anyone who knows what a Pod is.&lt;/p>
&lt;h2 id="architecture-four-pieces-visible-from-outside">Architecture: four pieces visible from outside&lt;/h2>
&lt;p>Hubble is made up of four logical components, all optional depending on what you want to do:&lt;/p>
&lt;h3 id="1-hubble-server-embedded-in-every-cilium-agent">1. Hubble Server (embedded in every Cilium agent)&lt;/h3>
&lt;p>It lives inside the Cilium agent process (it is not a separate binary). Each node exposes a gRPC endpoint locally on the Unix socket &lt;code>/var/run/cilium/hubble.sock&lt;/code>. The server listens to the events the eBPF programs emit to the ringbuffer, enriches them with Kubernetes metadata (which the agent already has in memory), and makes them available to consumers.&lt;/p>
&lt;p>Enabling it: &lt;code>--set hubble.enabled=true&lt;/code> in Cilium&amp;rsquo;s Helm chart. By default, the server is only accessible locally; if you want to consume it from another node, it has to be exposed (which is what Hubble Relay does).&lt;/p>
&lt;h3 id="2-hubble-relay-aggregator">2. Hubble Relay (aggregator)&lt;/h3>
&lt;p>It is a separate Deployment (typically 1 replica, scalable) that connects to every Hubble server in the cluster and aggregates their streams into a single API. When your CLI or UI asks for &amp;ldquo;the last 1000 flows in the cluster&amp;rdquo;, the Relay collects them in parallel from all nodes and returns the union.&lt;/p>
&lt;p>Enabling it: &lt;code>--set hubble.relay.enabled=true&lt;/code>. Without the Relay you only see the traffic of the node you are connected to, which is useful for local debugging but not for a cluster-wide view.&lt;/p>
&lt;h3 id="3-hubble-cli-hubble">3. Hubble CLI (&lt;code>hubble&lt;/code>)&lt;/h3>
&lt;p>A Go binary that speaks gRPC with the Relay (or with a local Hubble server). It supports two main modes:&lt;/p>
&lt;ul>
&lt;li>&lt;code>hubble observe&lt;/code>: a real-time stream of flows, with very expressive filters (by namespace, pod, port, verdict, protocol, label).&lt;/li>
&lt;li>&lt;code>hubble status&lt;/code>: the state of the Hubble cluster (how many nodes connected, lag, flow rate).&lt;/li>
&lt;/ul>
&lt;p>And the equivalent of &lt;code>tcpdump&lt;/code>&amp;rsquo;s pcap dump: &lt;code>hubble observe --output jsonpb &amp;gt; flows.json&lt;/code> to process afterwards with &lt;code>jq&lt;/code> or other tools.&lt;/p>
&lt;h3 id="4-hubble-ui">4. Hubble UI&lt;/h3>
&lt;p>A web frontend that connects to Hubble Relay and shows:&lt;/p>
&lt;ul>
&lt;li>A service graph in real time (which Pod talks to which Service, which protocols it uses, which verdict).&lt;/li>
&lt;li>A filterable list of flows.&lt;/li>
&lt;li>&lt;strong>L7 details&lt;/strong> where there are any (HTTP method/path/status, DNS query/response).&lt;/li>
&lt;/ul>
&lt;p>Enabling it: &lt;code>--set hubble.ui.enabled=true&lt;/code>. Useful for presentations to non-CLI teams; it does not replace the CLI for serious debugging.&lt;/p>
&lt;div class="diagram" style="max-width:720px;margin:1.5rem auto;">
&lt;svg viewBox="0 0 720 290" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Hubble architecture">
&lt;style>.title{font:600 13px sans-serif;fill:#222}.lbl{font:600 12px sans-serif;fill:#222}.sm{font:11px sans-serif;fill:#555}.box{stroke:#444;stroke-width:1.4}.k{fill:#ffe9d6}.s{fill:#d6eaff}.r{fill:#d9f5d6}.c{fill:#e9d6f5}.arr{stroke:#666;stroke-width:1.4;fill:none;marker-end:url(#hh)}&lt;/style>
&lt;defs>&lt;marker id="hh" 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="360" y="18" text-anchor="middle" class="title">Hubble architecture: 4 pieces, eBPF as the only data source&lt;/text>
&lt;rect x="30" y="40" width="200" height="50" rx="6" class="box k"/>
&lt;text x="130" y="60" text-anchor="middle" class="lbl">eBPF (kernel)&lt;/text>
&lt;text x="130" y="78" text-anchor="middle" class="sm">Cilium programs&lt;/text>
&lt;rect x="30" y="115" width="200" height="50" rx="6" class="box s"/>
&lt;text x="130" y="135" text-anchor="middle" class="lbl">Hubble Server (node)&lt;/text>
&lt;text x="130" y="153" text-anchor="middle" class="sm">local grpc, inside the agent&lt;/text>
&lt;rect x="270" y="115" width="180" height="50" rx="6" class="box s"/>
&lt;text x="360" y="135" text-anchor="middle" class="lbl">Hubble Server (node N)&lt;/text>
&lt;text x="360" y="153" text-anchor="middle" class="sm">one per node&lt;/text>
&lt;rect x="490" y="115" width="200" height="50" rx="6" class="box s"/>
&lt;text x="590" y="135" text-anchor="middle" class="lbl">Hubble Server (node …)&lt;/text>
&lt;text x="590" y="153" text-anchor="middle" class="sm">N agents = N servers&lt;/text>
&lt;rect x="220" y="190" width="280" height="50" rx="6" class="box r"/>
&lt;text x="360" y="210" text-anchor="middle" class="lbl">Hubble Relay (Deployment)&lt;/text>
&lt;text x="360" y="228" text-anchor="middle" class="sm">aggregates gRPC streams from all nodes&lt;/text>
&lt;rect x="80" y="245" width="160" height="35" rx="6" class="box c"/>
&lt;text x="160" y="266" text-anchor="middle" class="lbl">Hubble CLI&lt;/text>
&lt;rect x="290" y="245" width="160" height="35" rx="6" class="box c"/>
&lt;text x="370" y="266" text-anchor="middle" class="lbl">Hubble UI&lt;/text>
&lt;rect x="500" y="245" width="180" height="35" rx="6" class="box c"/>
&lt;text x="590" y="266" text-anchor="middle" class="lbl">Prometheus / OTLP&lt;/text>
&lt;path class="arr" d="M130,90 L130,115"/>
&lt;path class="arr" d="M130,165 L290,190"/>
&lt;path class="arr" d="M360,165 L360,190"/>
&lt;path class="arr" d="M590,165 L430,190"/>
&lt;path class="arr" d="M340,240 L200,245"/>
&lt;path class="arr" d="M360,240 L370,245"/>
&lt;path class="arr" d="M380,240 L560,245"/>
&lt;/svg>
&lt;/div>
&lt;h2 id="what-you-see-the-hubble-flow-log-from-the-inside">What you see: the Hubble flow log from the inside&lt;/h2>
&lt;p>A Hubble flow in JSON format looks roughly like this (simplified):&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-json" data-lang="json">&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="nt">&amp;#34;time&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;2026-05-19T03:12:45.182Z&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="nt">&amp;#34;verdict&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;FORWARDED&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="nt">&amp;#34;source&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="nt">&amp;#34;ID&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="mi">5482&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;identity&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="mi">24871&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;namespace&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;prod-api&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="nt">&amp;#34;labels&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;app=checkout&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s2">&amp;#34;team=payments&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="nt">&amp;#34;pod_name&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;checkout-7c9f-x8j2&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="nt">&amp;#34;workloads&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="p">[{&lt;/span>&lt;span class="nt">&amp;#34;name&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;checkout&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nt">&amp;#34;kind&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;Deployment&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="nt">&amp;#34;destination&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="nt">&amp;#34;ID&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="mi">12041&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;identity&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="mi">18356&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;namespace&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;prod-db&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="nt">&amp;#34;labels&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;app=postgres&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s2">&amp;#34;tier=primary&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="nt">&amp;#34;pod_name&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;postgres-0&amp;#34;&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="nt">&amp;#34;Type&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;L3_L4&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="nt">&amp;#34;l4&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="nt">&amp;#34;TCP&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="nt">&amp;#34;source_port&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="mi">41982&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;destination_port&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="mi">5432&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;flags&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="p">{&lt;/span>&lt;span class="nt">&amp;#34;SYN&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="kc">true&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="nt">&amp;#34;node_name&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;rke2-worker-03&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="nt">&amp;#34;Summary&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;TCP Flags: SYN&amp;#34;&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>When L7 parsing is active (via embedded Envoy or Hubble&amp;rsquo;s lightweight parser), the same flow adds:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-json" data-lang="json">&lt;span class="line">&lt;span class="cl">&lt;span class="s2">&amp;#34;l7&amp;#34;&lt;/span>&lt;span class="err">:&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;type&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;REQUEST&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="nt">&amp;#34;http&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="nt">&amp;#34;code&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="mi">200&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;method&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;GET&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="nt">&amp;#34;url&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;/api/v1/cart/items&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="nt">&amp;#34;protocol&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;HTTP/1.1&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="nt">&amp;#34;headers&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="p">[{&lt;/span>&lt;span class="nt">&amp;#34;key&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;user-agent&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nt">&amp;#34;value&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;checkout/1.4.2&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 protocols natively supported for L7 parsing:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>HTTP/1.1 and HTTP/2&lt;/strong> (including gRPC over HTTP/2).&lt;/li>
&lt;li>&lt;strong>DNS&lt;/strong> (queries and responses, with domains, types, response codes).&lt;/li>
&lt;li>&lt;strong>Kafka&lt;/strong> (topics, API keys).&lt;/li>
&lt;li>&lt;strong>TLS handshake&lt;/strong> (SNI, not the encrypted payload by default).&lt;/li>
&lt;li>&lt;strong>MySQL, Cassandra&lt;/strong> (with optional modules).&lt;/li>
&lt;/ul>
&lt;p>For HTTP and gRPC, Cilium can enable the embedded Envoy proxy for the flows you want to inspect (not all of them; it is selective via &lt;code>CiliumNetworkPolicy&lt;/code> with L7 rules). Without Envoy there is lightweight parsing, but less detailed.&lt;/p>
&lt;h2 id="verdict-and-drop-attribution">Verdict and drop attribution&lt;/h2>
&lt;p>Every flow has a &lt;code>verdict&lt;/code>: &lt;code>FORWARDED&lt;/code>, &lt;code>DROPPED&lt;/code>, &lt;code>ERROR&lt;/code>, &lt;code>AUDIT&lt;/code>, &lt;code>REDIRECTED&lt;/code>, &lt;code>TRACED&lt;/code>, &lt;code>TRANSLATED&lt;/code>. For the &lt;code>DROPPED&lt;/code> case, Hubble includes a structured reason (&lt;code>drop_reason&lt;/code>) and, since Cilium 1.19, &lt;strong>the exact NetworkPolicy&lt;/strong> that caused it.&lt;/p>
&lt;p>That last point changes day-to-day operations. Before, when a pod could not talk to another one, the debug flow was:&lt;/p>
&lt;ol>
&lt;li>See the dropped flow in Hubble.&lt;/li>
&lt;li>Look at every CiliumNetworkPolicy in the namespace.&lt;/li>
&lt;li>Reason manually about which one, with which labels, is blocking it.&lt;/li>
&lt;/ol>
&lt;p>With Cilium 1.19&amp;rsquo;s attribution, the &lt;code>policy_match_info&lt;/code> field tells you directly &amp;ldquo;it was dropped by the &lt;code>frontend-egress&lt;/code> policy, rule 3&amp;rdquo;. You go from &amp;ldquo;Sherlock Holmes for 20 minutes&amp;rdquo; to &amp;ldquo;kubectl get -o yaml of that specific policy&amp;rdquo;.&lt;/p>
&lt;h2 id="prometheus-metrics-and-grafana-dashboards">Prometheus metrics and Grafana dashboards&lt;/h2>
&lt;p>Hubble also exposes aggregated metrics in Prometheus format, separate from the gRPC flow stream. Enabling it: &lt;code>--set hubble.metrics.enabled=true&lt;/code> (Helm) plus a list of the set you want to export.&lt;/p>
&lt;p>The usual metric groups:&lt;/p>
&lt;ul>
&lt;li>&lt;code>flow&lt;/code>: total flows by verdict, source/dest, protocol.&lt;/li>
&lt;li>&lt;code>http&lt;/code>: requests by method, response code, latency (histogram).&lt;/li>
&lt;li>&lt;code>dns&lt;/code>: queries, response codes, top-N domains.&lt;/li>
&lt;li>&lt;code>tcp&lt;/code>: handshakes, retransmissions, congestion window.&lt;/li>
&lt;li>&lt;code>drop&lt;/code>: drops by reason, with NetworkPolicy attribution.&lt;/li>
&lt;li>&lt;code>port-distribution&lt;/code>: histogram of active ports.&lt;/li>
&lt;li>&lt;code>policy&lt;/code>: hits by policy and verdict.&lt;/li>
&lt;/ul>
&lt;p>These metrics have rich K8s labels (&lt;code>source_workload&lt;/code>, &lt;code>destination_workload&lt;/code>, &lt;code>namespace&lt;/code>, and so on) that make them pivotable in Grafana. There are &lt;a href="https://grafana.com/grafana/dashboards/?search=hubble">prebuilt dashboards on Grafana Labs&lt;/a> covering the common cases; importing one and having immediate visibility costs five minutes.&lt;/p>
&lt;p>Cost: metrics with many K8s labels can &lt;strong>blow up cardinality&lt;/strong> in Prometheus. For large clusters (&amp;gt;1,000 pods), it is worth reviewing which set you export and using drop rules in Prometheus to keep a lid on it.&lt;/p>
&lt;h2 id="deployment-helm-on-one-screen">Deployment: Helm on one screen&lt;/h2>
&lt;p>The canonical Cilium install with full Hubble:&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"># values.yaml&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">hubble&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">metrics&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>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="l">dns:query;ignoreAAAA&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="l">drop&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="l">tcp&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="l">flow&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="l">port-distribution&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="l">icmp&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="l">httpV2:exemplars=true;labelsContext=source_ip,source_namespace,source_workload,destination_ip,destination_namespace,destination_workload,traffic_direction&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">serviceMonitor&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 class="c"># auto-discovered by kube-prometheus-stack&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">relay&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">rollOutPods&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">ui&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">rollOutPods&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">ingress&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">className&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">cilium&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">hosts&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="l">hubble.example.local&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>And the install itself:&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">helm upgrade --install cilium cilium/cilium -n kube-system -f values.yaml
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>After installing, validate with &lt;code>cilium status&lt;/code> (the Cilium CLI) that the Hubble section shows OK, and try your first flow with:&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">cilium hubble observe --namespace prod-api --pod checkout-7c9f-x8j2
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h2 id="state-of-the-art-in-2026">State of the art in 2026&lt;/h2>
&lt;p>&lt;a href="https://www.infoq.com/news/2026/02/cilium-119/">Cilium 1.19 was released in February 2026&lt;/a>, marking the project&amp;rsquo;s tenth anniversary. Hubble reached version 1.19.3 on 22 April 2026. The relevant news:&lt;/p>
&lt;h3 id="direct-attribution-of-drops-to-a-networkpolicy">Direct attribution of drops to a NetworkPolicy&lt;/h3>
&lt;p>Already covered above; it is probably the most valuable operational change in the release. Any dropped flow carries the name, namespace and specific rule of the policy responsible. It is also available via Prometheus metrics, which allows alerts of the form &amp;ldquo;policy X is dropping &amp;gt;N requests/second&amp;rdquo;.&lt;/p>
&lt;h3 id="tracing-with-ip-options">Tracing with IP options&lt;/h3>
&lt;p>Hubble can now trace individual packets with IP options enabled. It is a mechanism similar to traceroute, but at L3: you put a mark on the packet and Cilium reports it every time the packet crosses a node or an eBPF decision. Useful for debugging multi-cluster paths, fabric mesh, or NetworkPolicies applied at different layers.&lt;/p>
&lt;h3 id="filtering-by-encryption-status">Filtering by encryption status&lt;/h3>
&lt;p>A new CLI flag: &lt;code>hubble observe --encryption-status=encrypted&lt;/code> (or &lt;code>unencrypted&lt;/code>). Useful for validating deployments with WireGuard or IPsec enabled pod-to-pod: you confirm that the traffic that &lt;strong>should&lt;/strong> be encrypted is, and you spot regressions quickly.&lt;/p>
&lt;h3 id="hubble-field-mask-api-stabilised">Hubble field mask API stabilised&lt;/h3>
&lt;p>The &lt;code>field_mask&lt;/code> lets you ask for only the parts of the flow you care about, hugely reducing bandwidth and processing when all you need is, say, source/dest and verdict. It used to be experimental; now it is stable and it is default-on in the CLI.&lt;/p>
&lt;h3 id="ai-driven-anomaly-detection-predictive-security">AI-driven anomaly detection (predictive security)&lt;/h3>
&lt;p>This is the most talked-about addition of 2026. Cilium 1.19 adds hooks so that an external consumer, typically an ML system, can process flows in streaming and detect statistical anomalies: pods that suddenly talk to new destinations, latency spikes on an API, odd DNS sequences. The detection part happens outside the Cilium agent (nobody wants heavy ML in the datapath), but Cilium exposes the flows with the pre-computed features the model needs. The published use cases focus on IoT and 5G, where traffic is high in volume and low in variety, ideal conditions for anomaly detection.&lt;/p>
&lt;h3 id="scaling-to-10000-pods">Scaling to 10,000+ pods&lt;/h3>
&lt;p>Cilium 1.19 has done serious work on scalability: Hubble Relay can now aggregate streams from hundreds of nodes without saturating; the default field_mask reduces inter-node bandwidth; and flows can be sampled under high load if your use is statistical analysis rather than forensic debugging.&lt;/p>
&lt;h3 id="cilium-120-in-development">Cilium 1.20 in development&lt;/h3>
&lt;p>&lt;a href="https://docs.cilium.io/en/latest/operations/upgrade/">Cilium 1.20&lt;/a> is in the development branch. The most relevant bits for Hubble:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Unification of &lt;code>preferIpv6&lt;/code>&lt;/strong>: the &lt;code>hubble.preferIpv6&lt;/code> flag was deprecated in favour of the global &lt;code>preferIpv6&lt;/code> that applies to every Cilium component.&lt;/li>
&lt;li>&lt;strong>&lt;code>tetragon-python&lt;/code> SDK&lt;/strong>: although it belongs to Tetragon, not Hubble, it sets a trend, namely eBPF policies written in Python instead of YAML. Hubble will probably follow a similar path.&lt;/li>
&lt;/ul>
&lt;h2 id="the-new-frontier-ebpf-and-ai-agents">The new frontier: eBPF and AI agents&lt;/h2>
&lt;p>So far, the classic Hubble content. But there is a 2026 twist worth covering, because it closes the loop with the other series on this blog.&lt;/p>
&lt;p>When a Kubernetes cluster starts running AI agents, such as Claude Code, Gemini CLI, or LangGraph-based agents calling APIs and MCP servers, the observability problem changes shape. It is no longer enough to know &amp;ldquo;which pod talked to which pod&amp;rdquo; (that is Hubble) or &amp;ldquo;which process ran what&amp;rdquo; (that is Tetragon). You need to know:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Which external APIs the agent is calling&lt;/strong> and with which prompts.&lt;/li>
&lt;li>&lt;strong>Which MCP tools it is invoking&lt;/strong>, with which arguments.&lt;/li>
&lt;li>&lt;strong>How many tokens it consumes&lt;/strong>, which model it picks, how much it costs.&lt;/li>
&lt;li>&lt;strong>Whether the agent drifts&lt;/strong> from expected behaviour (out-of-policy queries, jailbreak attempts, secret leakage).&lt;/li>
&lt;/ul>
&lt;p>The traditional solutions, instrumenting the agent&amp;rsquo;s code with OpenTelemetry or parsing structured logs, do not work well when the agent is a third-party binary (Anthropic&amp;rsquo;s Claude Code, Google&amp;rsquo;s Gemini CLI) or when the MCP servers live in other languages with stdio as their transport.&lt;/p>
&lt;h3 id="agentsight-zero-instrumentation-for-llm-agents">AgentSight: zero-instrumentation for LLM agents&lt;/h3>
&lt;p>&lt;a href="https://github.com/eunomia-bpf/agentsight">AgentSight&lt;/a> (a project from the &lt;code>eunomia-bpf&lt;/code> group, the same ecosystem behind several high-profile eBPF runtimes) attacks this problem with the same philosophy as Hubble: do not instrument; listen. It puts eBPF hooks at two critical points:&lt;/p>
&lt;ol>
&lt;li>
&lt;p>&lt;strong>uprobes on SSL/TLS libraries&lt;/strong> (&lt;code>libssl&lt;/code>, &lt;code>boringssl&lt;/code>, &lt;code>rustls&lt;/code>). It captures the plaintext before encryption on send and after decryption on recv. For an HTTP call to &lt;code>https://api.anthropic.com/v1/messages&lt;/code>, AgentSight sees the full JSON of the prompt and the response without decrypting anything in transit, simply because it has reached the syscall level before the TLS layer does its work.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;strong>&lt;code>stdiocap&lt;/code> BPF&lt;/strong>: it captures &lt;code>read&lt;/code>, &lt;code>write&lt;/code> and &lt;code>dup&lt;/code> on a process&amp;rsquo;s stdin/stdout/stderr file descriptors. This is what makes it possible to observe MCP servers that speak stdio with their client, the usual pattern for local MCP servers. You capture the JSON-RPC going back and forth without either the client or the server knowing.&lt;/p>
&lt;/li>
&lt;/ol>
&lt;p>Reported overhead: &amp;lt;3% CPU, comparable to Hubble in its own regime.&lt;/p>
&lt;h3 id="how-it-fits-with-hubble-and-tetragon">How it fits with Hubble and Tetragon&lt;/h3>
&lt;p>The three complement each other cleanly:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Hubble&lt;/strong> tells you: &amp;ldquo;the agent&amp;rsquo;s pod opened a TCP connection to &lt;code>api.anthropic.com:443&lt;/code> with verdict ALLOW&amp;rdquo;.&lt;/li>
&lt;li>&lt;strong>Tetragon&lt;/strong> tells you: &amp;ldquo;the &lt;code>claude-code&lt;/code> process with PID 1843 did a &lt;code>connect()&lt;/code> to that IP&amp;rdquo; (plus the binary, the arguments, the pod namespace).&lt;/li>
&lt;li>&lt;strong>AgentSight&lt;/strong> tells you: &amp;ldquo;the HTTPS content of that connection was a prompt &lt;code>messages=[{role:'user', content:'analyze this repo and modify the firewall config'}]&lt;/code> and the response included a tool call to &lt;code>read_file&lt;/code> with argument &lt;code>/etc/passwd&lt;/code>&amp;rdquo;.&lt;/li>
&lt;/ul>
&lt;p>It is the difference between flow, process and semantics. For a security team that wants to watch AI agents in production, all three are necessary. For anyone who wants to understand cost, all three are useful (Hubble for network latency, Tetragon for resource usage, AgentSight for tokens and chosen model).&lt;/p>
&lt;h3 id="emerging-use-cases">Emerging use cases&lt;/h3>
&lt;p>The patterns consolidating in 2026:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Agent audit trail&lt;/strong>: recording every LLM call and every tool call for compliance, especially in regulated sectors.&lt;/li>
&lt;li>&lt;strong>Jailbreak and prompt injection detection&lt;/strong>: applying rules over the prompts captured by AgentSight (similar to Tetragon&amp;rsquo;s TracingPolicy, but over semantic content).&lt;/li>
&lt;li>&lt;strong>Cost accountability&lt;/strong>: seeing which team/agent consumes which tokens, without instrumenting.&lt;/li>
&lt;li>&lt;strong>Replay and debugging&lt;/strong>: reproducing an agent&amp;rsquo;s reasoning in production without asking it to run again (which is non-deterministic).&lt;/li>
&lt;/ul>
&lt;p>It is a young field, AgentSight is months old rather than years, but the &amp;ldquo;eBPF as zero-instrumentation observability&amp;rdquo; pattern is very clearly spreading beyond network and process. The coming year will see consolidation and, probably, native integration with Hubble.&lt;/p>
&lt;h2 id="common-hubble-use-cases">Common Hubble use cases&lt;/h2>
&lt;p>Back to Hubble proper, the cases in which any organisation deploys it:&lt;/p>
&lt;h3 id="1-networkpolicy-debugging">1. NetworkPolicy debugging&lt;/h3>
&lt;p>The classic use: &amp;ldquo;this pod cannot reach this Service&amp;rdquo;. Without Hubble, it meant SSH, tcpdump, comparing rules. With Hubble:&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">hubble observe --from-pod prod-api/checkout --to-pod prod-db/postgres --verdict DROPPED
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>If there are drops, you see the policy responsible (Cilium 1.19+). If there are no drops, the problem is not policy: it is DNS, routing or the target service.&lt;/p>
&lt;h3 id="2-auditing-inter-namespace-communication">2. Auditing inter-namespace communication&lt;/h3>
&lt;p>For compliance: validating that isolated namespaces are not communicating against what was declared.&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">hubble observe --from-namespace prod-payments --to-namespace &lt;span class="s1">&amp;#39;NOT prod-db&amp;#39;&lt;/span> --output json
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h3 id="3-exfiltration-detection">3. Exfiltration detection&lt;/h3>
&lt;p>Outbound traffic to suspicious public destinations. Hubble detects them by IP/SNI, not by payload (which is encrypted):&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">hubble observe --to-fqdn &lt;span class="s1">&amp;#39;NOT *.example.com&amp;#39;&lt;/span> --to-fqdn &lt;span class="s1">&amp;#39;NOT *.internal&amp;#39;&lt;/span> --protocol tcp
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Combined with Prometheus metrics and Grafana alerts, this gives you an exfiltration radar at zero cost.&lt;/p>
&lt;h3 id="4-real-time-service-slos">4. Real-time service SLOs&lt;/h3>
&lt;p>The &lt;code>hubble:http:response_time_seconds&lt;/code> metrics with &lt;code>source_workload&lt;/code>, &lt;code>destination_workload&lt;/code>, &lt;code>method&lt;/code> and &lt;code>status_code&lt;/code> labels allow SLO dashboards without needing to instrument the apps. The SRE sees the p95 latency of &lt;code>checkout → catalog&lt;/code> directly.&lt;/p>
&lt;h3 id="5-performance-debugging">5. Performance debugging&lt;/h3>
&lt;p>&lt;code>hubble:tcp:retransmissions_total&lt;/code> and &lt;code>hubble:tcp:flags_total{flag=&amp;quot;RST&amp;quot;}&lt;/code> are early signals of network problems. A rise correlated with a latency regression points you at something in the infrastructure (NIC, switch, MTU) before you go down to investigate the app.&lt;/p>
&lt;h3 id="6-post-incident-forensics">6. Post-incident forensics&lt;/h3>
&lt;p>Configuring Hubble to export flows to persistent storage (via OTLP to Tempo/Loki, or &lt;code>hubble observe --output jsonpb&lt;/code> to S3) gives you forensic capability: if at T+30 days you detect that something was wrong at T, you can reconstruct the traffic.&lt;/p>
&lt;h2 id="hubble-and-the-rest-of-the-observability-stack">Hubble and the rest of the observability stack&lt;/h2>
&lt;p>Hubble does not replace Prometheus, Loki, Tempo or Jaeger; it complements them:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Prometheus&lt;/strong>: receives Hubble&amp;rsquo;s aggregated metrics. Hubble exports a native Prometheus endpoint.&lt;/li>
&lt;li>&lt;strong>Loki&lt;/strong>: receives the structured flow logs if you export them as logs. Hubble has no native Loki exporter, but a Fluent Bit with an OTLP plugin or a custom one bridges it easily.&lt;/li>
&lt;li>&lt;strong>Tempo / Jaeger&lt;/strong>: the Cilium Operator has an OTLP exporter of flows in trace format (each HTTP/gRPC flow is a span). It integrates with Tempo or any other OTLP tracing backend.&lt;/li>
&lt;li>&lt;strong>Grafana&lt;/strong>: there are already public Hubble dashboards. Combined with Prometheus, Loki and Tempo, they give you a unified panel: metrics, logs, traces, all correlated by K8s labels.&lt;/li>
&lt;/ul>
&lt;p>The full-stack pile seen in production in 2026 (described in &lt;a href="https://dev.to/x4nent/building-a-production-ebpf-observability-security-stack-for-kubernetes-in-2026-5051">Building a Production eBPF Observability &amp;amp; Security Stack for Kubernetes in 2026&lt;/a>):&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Data&lt;/strong>: Cilium + Hubble (network), Tetragon (process), AgentSight (AI agent).&lt;/li>
&lt;li>&lt;strong>Pipeline&lt;/strong>: OTLP Collector as the single router.&lt;/li>
&lt;li>&lt;strong>Storage&lt;/strong>: Prometheus (metrics), Loki (logs), Tempo (traces).&lt;/li>
&lt;li>&lt;strong>UI&lt;/strong>: Grafana with domain-specific dashboards.&lt;/li>
&lt;li>&lt;strong>Alerting&lt;/strong>: AlertManager with rules over the Hubble + Tetragon metrics.&lt;/li>
&lt;/ul>
&lt;h2 id="comparison-with-the-alternatives">Comparison with the alternatives&lt;/h2>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>System&lt;/th>
&lt;th>Layer&lt;/th>
&lt;th>Focus&lt;/th>
&lt;th>Model&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>&lt;strong>Hubble&lt;/strong>&lt;/td>
&lt;td>L3-L7 network&lt;/td>
&lt;td>K8s cluster with Cilium&lt;/td>
&lt;td>eBPF, pull metrics, push flows gRPC&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>GKE Dataplane v2 obs&lt;/strong>&lt;/td>
&lt;td>L3-L7 network&lt;/td>
&lt;td>GKE managed&lt;/td>
&lt;td>eBPF (Cilium-based, managed)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Tigera Calico Whisker&lt;/strong>&lt;/td>
&lt;td>L3-L7 network&lt;/td>
&lt;td>Cluster with Calico&lt;/td>
&lt;td>eBPF + pcap, own UI&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Tetragon&lt;/strong>&lt;/td>
&lt;td>Process/syscall&lt;/td>
&lt;td>K8s cluster&lt;/td>
&lt;td>eBPF, push events gRPC&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Falco&lt;/strong>&lt;/td>
&lt;td>Process/syscall&lt;/td>
&lt;td>K8s cluster&lt;/td>
&lt;td>eBPF in userspace or kernel module&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>AgentSight&lt;/strong>&lt;/td>
&lt;td>LLM agent&lt;/td>
&lt;td>Agentic systems&lt;/td>
&lt;td>eBPF (SSL uprobes + stdio)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Beyla&lt;/strong> (Grafana)&lt;/td>
&lt;td>Application&lt;/td>
&lt;td>App L7 + tracing&lt;/td>
&lt;td>eBPF (uprobes on libs)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Pixie&lt;/strong>&lt;/td>
&lt;td>App + system&lt;/td>
&lt;td>Broad cluster visibility&lt;/td>
&lt;td>eBPF + PXL script&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Parca&lt;/strong>&lt;/td>
&lt;td>CPU/mem profiling&lt;/td>
&lt;td>Performance&lt;/td>
&lt;td>eBPF profile sampling&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>If your CNI is Cilium, Hubble is the natural entry point and it does not compete with the rest: it complements them. For Calico clusters, Whisker is the equivalent. For profiling, Parca. For AI agents, AgentSight. The era of &amp;ldquo;one tool for everything&amp;rdquo; is passing: the modern stack combines several specialised pieces, all eBPF-based, exposed via OTLP.&lt;/p>
&lt;h2 id="operational-traps">Operational traps&lt;/h2>
&lt;h3 id="cardinality-in-prometheus">Cardinality in Prometheus&lt;/h3>
&lt;p>Hubble metrics with all the K8s labels can blow up Prometheus. &lt;strong>Measure cardinality before exporting everything.&lt;/strong> The most prolific metrics are &lt;code>flow&lt;/code> and &lt;code>httpV2&lt;/code>; start with &lt;code>drop&lt;/code> and &lt;code>port-distribution&lt;/code> and add the rest incrementally.&lt;/p>
&lt;h3 id="l7-visibility-costs-cpu">L7 visibility costs CPU&lt;/h3>
&lt;p>Enabling L7 parsing via embedded Envoy adds load to the agent (not to the base datapath, but yes to the node&amp;rsquo;s envoy proxy). For heavy HTTP traffic, measure. For flows where you only need L4, leave Envoy disabled.&lt;/p>
&lt;h3 id="hubble-relay-without-ha">Hubble Relay without HA&lt;/h3>
&lt;p>A single Relay replica is a single point of failure for the CLI and the UI (not for the local agent, which keeps working). For production, deploy with &lt;code>replicas: 2+&lt;/code> and &lt;code>topologySpreadConstraints&lt;/code> so both do not go down together.&lt;/p>
&lt;h3 id="encryption-status-reporting-depends-on-the-cilium-config">Encryption status reporting depends on the Cilium config&lt;/h3>
&lt;p>The new &lt;code>--encryption-status&lt;/code> filter only gives real data if Cilium has encryption enabled (WireGuard or IPsec). Without that, everything is &lt;code>unencrypted&lt;/code> and the filter contributes nothing.&lt;/p>
&lt;h3 id="ui-exposed-without-auth">UI exposed without auth&lt;/h3>
&lt;p>Hubble UI has no native auth. If you expose it through Ingress, &lt;strong>there has to be authentication in front of it&lt;/strong>: OIDC via oauth2-proxy, mTLS, IP allowlist. It is not optional.&lt;/p>
&lt;h3 id="storage-that-does-not-scale">Storage that does not scale&lt;/h3>
&lt;p>If you keep flows for days for forensics, the volume is serious. For a cluster with 100 active pods, easily 1-10 GB/day of flow logs. Plan the lifecycle (compaction, retention, cold storage) before enabling it.&lt;/p>
&lt;h2 id="what-we-have-not-covered">What we have not covered&lt;/h2>
&lt;ul>
&lt;li>&lt;strong>Mesh / multi-cluster Hubble&lt;/strong>: aggregating flows from several Cilium clusters into a single Relay. Use case: a cross-cluster view, debugging a distributed service mesh.&lt;/li>
&lt;li>&lt;code>hubble export&lt;/code>: local persistence on the agent&amp;rsquo;s disk for forensics with low retention.&lt;/li>
&lt;li>&lt;strong>Anomaly detection with your own models&lt;/strong>: how to connect the gRPC stream to a custom ML consumer.&lt;/li>
&lt;li>&lt;strong>AgentSight in depth&lt;/strong>: the project deserves its own article. Next instalment.&lt;/li>
&lt;li>&lt;strong>eBPF for LLM serving profiling&lt;/strong>: how to measure vLLM&amp;rsquo;s TTFT, TPOT and throughput without instrumenting, using uprobes on libcudart.&lt;/li>
&lt;/ul>
&lt;h2 id="references">References&lt;/h2>
&lt;p>Hubble and Cilium:&lt;/p>
&lt;ul>
&lt;li>&lt;a href="https://github.com/cilium/hubble">Hubble GitHub&lt;/a> — main repo.&lt;/li>
&lt;li>&lt;a href="https://docs.cilium.io/en/stable/observability/hubble/">Hubble — Network Observability (Cilium docs)&lt;/a> — official reference.&lt;/li>
&lt;li>&lt;a href="https://www.infoq.com/news/2026/02/cilium-119/">Cilium 1.19 release notes (InfoQ, feb 2026)&lt;/a> — tenth anniversary and 1.19 news.&lt;/li>
&lt;li>&lt;a href="https://github.com/cilium/cilium/releases">Cilium releases&lt;/a> — all releases.&lt;/li>
&lt;li>&lt;a href="https://grafana.com/grafana/dashboards/19423-hubble-l7-http-metrics-by-workload/">Hubble L7 HTTP Metrics — Grafana dashboard 19423&lt;/a> — ready to import.&lt;/li>
&lt;li>&lt;a href="https://cloud-cod.com/index.php/2026/03/03/end-to-end-l7-visibility-with-cilium-hubble/">End‑to‑end L7 Visibility with Cilium Hubble (cloud-cod.com, mar 2026)&lt;/a>.&lt;/li>
&lt;li>&lt;a href="https://www.youngju.dev/blog/cilium/cilium_hubble_observability.en">Cilium Hubble Observability Platform Internal Analysis (Young-ju)&lt;/a>.&lt;/li>
&lt;li>&lt;a href="https://johal.in/ciliumnetworkpolicy-python-hubble-l7-visibility-2026/">CiliumNetworkPolicy Python Hubble: L7 Visibility 2026&lt;/a> — one of the threads on the Python SDK.&lt;/li>
&lt;/ul>
&lt;p>State of the art 2026 and the full stack:&lt;/p>
&lt;ul>
&lt;li>&lt;a href="https://dev.to/x4nent/building-a-production-ebpf-observability-security-stack-for-kubernetes-in-2026-5051">Building a Production eBPF Observability &amp;amp; Security Stack for Kubernetes in 2026 (DEV)&lt;/a>.&lt;/li>
&lt;li>&lt;a href="https://www.cloudraft.io/blog/ebpf-based-network-observability-using-cilium-hubble">eBPF-Based Network Observability: Exploring Cilium Hubble and Alternatives (CloudRaft)&lt;/a>.&lt;/li>
&lt;/ul>
&lt;p>eBPF + AI agents:&lt;/p>
&lt;ul>
&lt;li>&lt;a href="https://github.com/eunomia-bpf/agentsight">AgentSight (GitHub eunomia-bpf)&lt;/a> — the referenced project.&lt;/li>
&lt;li>&lt;a href="https://klizosolutions.medium.com/harnessing-ebpf-for-high-performance-llm-workloads-a-cloud-native-guide-efb7d73e19ed">Harnessing eBPF for High‑Performance LLM Workloads (Klizo Solutions)&lt;/a>.&lt;/li>
&lt;/ul>
&lt;p>Cross-references:&lt;/p>
&lt;ul>
&lt;li>Part 1: &lt;a href="https://blog.lo0.es/en/posts/ebpf-zero-to-cilium-kernel-tcp-ip-bypass/">eBPF from zero to Cilium&lt;/a>.&lt;/li>
&lt;li>Part 2: &lt;a href="https://blog.lo0.es/en/posts/tetragon-cilium-security-syscalls-kernel/">Tetragon: Cilium&amp;rsquo;s security cousin&lt;/a>.&lt;/li>
&lt;li>LLM inference series: &lt;a href="https://blog.lo0.es/en/posts/kv-cache-working-memory-llm-inference/">KV cache&lt;/a>, &lt;a href="https://blog.lo0.es/en/posts/vllm-kubernetes-llm-inference-that-scales/">vLLM on K8s&lt;/a>, &lt;a href="https://blog.lo0.es/en/posts/pagedattention-vllm-block-manager/">PagedAttention&lt;/a>, &lt;a href="https://blog.lo0.es/en/posts/llm-inference-operators-kubernetes/">LLM K8s Operators&lt;/a> — where the traffic Hubble observes carries the prompts AgentSight inspects.&lt;/li>
&lt;/ul></description></item><item><title>Tetragon: Cilium's security cousin that sees every syscall in the kernel</title><link>https://blog.lo0.es/en/posts/tetragon-cilium-security-syscalls-kernel/</link><pubDate>Tue, 19 May 2026 05:00:00 +0200</pubDate><guid>https://blog.lo0.es/en/posts/tetragon-cilium-security-syscalls-kernel/</guid><description>&lt;h2 id="tldr">TL;DR&lt;/h2>
&lt;p>&lt;a href="https://tetragon.io/">Tetragon&lt;/a> is the runtime security and observability engine that the Cilium project published as a companion to the CNI. Its job is not to route packets, Cilium is already there for that, but to &lt;strong>observe what happens inside the node&amp;rsquo;s processes in real time&lt;/strong>: which binary runs in each pod, which files it opens, which syscalls it invokes, which capabilities it asks for, which network connections it establishes, which kernel modules get loaded. It does this by loading eBPF programs into the kernel&amp;rsquo;s hook points (kprobes, tracepoints, uprobes, LSM hooks) and filtering the relevant events &lt;strong>inside the kernel itself&lt;/strong> with a declarative language expressed as a CRD (&lt;code>TracingPolicy&lt;/code> and &lt;code>TracingPolicyNamespaced&lt;/code>). The result is a stream of events enriched with Kubernetes metadata (pod, namespace, labels) that costs less than 1% of CPU and, the thing that sets Tetragon apart from the competition, can &lt;strong>block actions inside the kernel&lt;/strong>, killing the process with &lt;code>SIGKILL&lt;/code> or overwriting a syscall&amp;rsquo;s return value, &lt;strong>before they finish executing&lt;/strong>, with no race conditions. Against Falco (which parses syscalls in userspace, 5-10% overhead, detection-only), Tetragon is &amp;ldquo;cheaper and with enforcement&amp;rdquo;; against the bare kernel, it is &amp;ldquo;a declarative layer your operations colleague can read&amp;rdquo;. This article is the extensive introduction you need to take it on seriously: architecture, all the hooks and selectors, the operating modes, a guide to use cases (exec auditing, sensitive file access, container escape, cryptomining, rootkit detection, network observability) and the traps you see in production.&lt;/p>
&lt;blockquote>
&lt;p>This article is part 2 of the eBPF series. Part 1, &lt;a href="https://blog.lo0.es/en/posts/ebpf-zero-to-cilium-kernel-tcp-ip-bypass/">eBPF from zero to Cilium: how the kernel learned to skip its own TCP/IP stack&lt;/a>, covered basic eBPF, the networking hooks (XDP, TC, sock_ops), how Cilium implements the datapath and the BGP Control Plane v2 CRDs. Here we take those same eBPF hooks and use them for something different: &lt;strong>observing and, if needed, stopping&lt;/strong> what the cluster&amp;rsquo;s processes do.&lt;/p>
&lt;/blockquote>
&lt;h2 id="the-analogy-auditd-on-steroids-in-ebpf">The analogy: auditd on steroids in eBPF&lt;/h2>
&lt;p>Anyone who has spent a few years administering Linux has used &lt;code>auditd&lt;/code>. It is the classic kernel subsystem for auditing syscalls: you configure a rule with &lt;code>auditctl&lt;/code> (for instance, &amp;ldquo;monitor any &lt;code>open&lt;/code> on &lt;code>/etc/shadow&lt;/code>&amp;rdquo;) and the kernel sends events to a userspace daemon that persists them. It works, but it has two limitations that weigh heavily on modern Kubernetes clusters:&lt;/p>
&lt;ol>
&lt;li>&lt;strong>No Kubernetes context.&lt;/strong> auditd reports processes by PID and UID. Knowing which pod, which namespace, which image, which labels, the information that actually matters when responding to an incident, requires correlating afterwards with data from cri-o or containerd. It is operationally miserable.&lt;/li>
&lt;li>&lt;strong>No granular enforcement.&lt;/strong> auditd can generate events, but it cannot take the decision to kill the offending process before the syscall finishes. You leave that to a higher layer that reads the events, processes them and kills the process… if it gets there in time. A race by design.&lt;/li>
&lt;/ol>
&lt;p>Tetragon is &lt;strong>auditd on steroids&lt;/strong>: the same conceptual ideas, hooks on syscalls and events to userspace, but implemented with modern eBPF, with filtering inside the kernel so you do not pay the cost of waking the daemon for every irrelevant syscall, with Kubernetes metadata injected by an agent that knows the cluster, and with actions executed inside the kernel itself without waiting for userspace to decide. If the rule says &amp;ldquo;kill any process that opens &lt;code>/etc/shadow&lt;/code> from the &lt;code>prod&lt;/code> namespace&amp;rdquo;, the decision is taken in the kernel kprobe and &lt;code>SIGKILL&lt;/code> is delivered before the &lt;code>open&lt;/code> completes. There is no race; there is no window between detection and action.&lt;/p>
&lt;h2 id="what-tetragon-is-architecturally">What Tetragon is, architecturally&lt;/h2>
&lt;p>Tetragon is an agent deployed as a &lt;code>DaemonSet&lt;/code> (one pod per node) and a set of CRDs that define the policies to apply. The agent has four responsibilities:&lt;/p>
&lt;ol>
&lt;li>&lt;strong>Load eBPF programs&lt;/strong> into the hook points the active TracingPolicies demand.&lt;/li>
&lt;li>&lt;strong>Maintain a cache of Kubernetes metadata&lt;/strong> (pods, namespaces, labels) by reading the API server, so it can enrich every event with the right context.&lt;/li>
&lt;li>&lt;strong>Collect the events&lt;/strong> the eBPF programs emit (via ring buffers) and serialise them.&lt;/li>
&lt;li>&lt;strong>Export the events&lt;/strong> to configurable destinations: &lt;code>stdout&lt;/code> JSON (typical in sidecars or log-collection agents), gRPC streaming (to consume them from Hubble or another consumer), a file, or Fluentd/Loki/SIEM.&lt;/li>
&lt;/ol>
&lt;p>The eBPF programs are not written by the user. Tetragon generates the bytecode from the TracingPolicies: it reads the declarative policy, decides which hooks to attack, which arguments to read from the kernel, which filters to apply inline and which actions to execute. The user only writes &lt;strong>YAML&lt;/strong>.&lt;/p>
&lt;div class="diagram" style="max-width:720px;margin:1.5rem auto;">
&lt;svg viewBox="0 0 720 280" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Tetragon architecture">
&lt;style>.title{font:600 13px sans-serif;fill:#222}.lbl{font:600 12px sans-serif;fill:#222}.sm{font:11px sans-serif;fill:#555}.box{stroke:#444;stroke-width:1.4}.k{fill:#ffe9d6}.u{fill:#d6eaff}.p{fill:#d9f5d6}.api{fill:#e9d6f5}.arr{stroke:#666;stroke-width:1.4;fill:none;marker-end:url(#h)}&lt;/style>
&lt;defs>&lt;marker id="h" 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="360" y="20" text-anchor="middle" class="title">Tetragon: control and data planes on a node&lt;/text>
&lt;rect x="40" y="50" width="200" height="70" rx="6" class="box k"/>
&lt;text x="140" y="70" text-anchor="middle" class="lbl">eBPF programs&lt;/text>
&lt;text x="140" y="90" text-anchor="middle" class="sm">kprobes, tracepoints,&lt;/text>
&lt;text x="140" y="105" text-anchor="middle" class="sm">uprobes, LSM&lt;/text>
&lt;rect x="40" y="160" width="200" height="60" rx="6" class="box u"/>
&lt;text x="140" y="183" text-anchor="middle" class="lbl">Tetragon agent&lt;/text>
&lt;text x="140" y="203" text-anchor="middle" class="sm">reads events from the ring buffer&lt;/text>
&lt;rect x="290" y="160" width="180" height="60" rx="6" class="box api"/>
&lt;text x="380" y="183" text-anchor="middle" class="lbl">Kubernetes API&lt;/text>
&lt;text x="380" y="203" text-anchor="middle" class="sm">pods, namespaces, labels&lt;/text>
&lt;rect x="510" y="50" width="180" height="70" rx="6" class="box p"/>
&lt;text x="600" y="70" text-anchor="middle" class="lbl">TracingPolicy CRDs&lt;/text>
&lt;text x="600" y="90" text-anchor="middle" class="sm">declarative YAML&lt;/text>
&lt;text x="600" y="105" text-anchor="middle" class="sm">cluster or namespaced&lt;/text>
&lt;rect x="510" y="160" width="180" height="60" rx="6" class="box u"/>
&lt;text x="600" y="183" text-anchor="middle" class="lbl">Exporters&lt;/text>
&lt;text x="600" y="203" text-anchor="middle" class="sm">stdout, gRPC, file, SIEM&lt;/text>
&lt;path class="arr" d="M510,80 L240,80"/>
&lt;text x="375" y="74" text-anchor="middle" class="sm">policies → bytecode&lt;/text>
&lt;path class="arr" d="M140,120 L140,160"/>
&lt;text x="160" y="143" text-anchor="middle" class="sm">events&lt;/text>
&lt;path class="arr" d="M290,190 L240,190"/>
&lt;text x="265" y="184" text-anchor="middle" class="sm">enrich&lt;/text>
&lt;path class="arr" d="M240,180 L510,180"/>
&lt;text x="375" y="174" text-anchor="middle" class="sm">enriched events&lt;/text>
&lt;text x="360" y="255" text-anchor="middle" class="sm">The arrows show data flow. TracingPolicies are compiled into eBPF programs;&lt;/text>
&lt;text x="360" y="270" text-anchor="middle" class="sm">events travel kernel → agent → exporter, decorated with K8s metadata along the way.&lt;/text>
&lt;/svg>
&lt;/div>
&lt;p>This separation of declarative policy → generated eBPF bytecode is what makes Tetragon usable. Writing eBPF programs by hand is a specialist&amp;rsquo;s job; writing a &lt;code>TracingPolicy&lt;/code> is the job of an SRE with a good example in front of them.&lt;/p>
&lt;h2 id="the-two-crds-tracingpolicy-and-tracingpolicynamespaced">The two CRDs: TracingPolicy and TracingPolicyNamespaced&lt;/h2>
&lt;p>Tetragon exposes exactly two main CRDs:&lt;/p>
&lt;ul>
&lt;li>&lt;code>TracingPolicy&lt;/code> (cluster-scoped, &lt;code>cilium.io/v1alpha1&lt;/code>): applies to the whole cluster, every node, every pod. Suitable for platform policies (the whole cluster must be audited the same way): for example, &amp;ldquo;log every &lt;code>execve&lt;/code> in every pod&amp;rdquo; or &amp;ldquo;kill any process that tries to load a kernel module&amp;rdquo;.&lt;/li>
&lt;li>&lt;code>TracingPolicyNamespaced&lt;/code> (namespaced, same group and version): defined inside a namespace and applied only to the pods of that namespace. Suitable for policies with per-tenant autonomy: for example, &amp;ldquo;in the &lt;code>prod-payments&lt;/code> namespace, kill any outbound &lt;code>connect&lt;/code> to an IP outside the corporate range&amp;rdquo;.&lt;/li>
&lt;/ul>
&lt;p>Both CRDs have exactly the same internal structure. The difference is one of scope. The distinction was introduced precisely to allow multi-tenancy: the central security team defines cluster-wide &lt;code>TracingPolicy&lt;/code> objects and each tenant can add its own with &lt;code>TracingPolicyNamespaced&lt;/code> without needing cluster-admin permissions.&lt;/p>
&lt;h2 id="anatomy-of-a-tracingpolicy">Anatomy of a TracingPolicy&lt;/h2>
&lt;p>A policy is made up of:&lt;/p>
&lt;ol>
&lt;li>&lt;strong>Hook points&lt;/strong>: which kernel events to observe.&lt;/li>
&lt;li>&lt;strong>Arguments&lt;/strong>: which data to read when the hook fires.&lt;/li>
&lt;li>&lt;strong>Selectors&lt;/strong>: filters evaluated inside the kernel to discard irrelevant events and, optionally, execute actions when they match.&lt;/li>
&lt;/ol>
&lt;h3 id="supported-hook-points">Supported hook points&lt;/h3>
&lt;p>The official documentation lists five families of hook points:&lt;/p>
&lt;ul>
&lt;li>&lt;code>kprobes&lt;/code>: hook a kernel function. Syscalls are a particular case (when &lt;code>syscall: true&lt;/code>) because their ABI differs from that of internal functions. Typical examples: &lt;code>sys_open&lt;/code>, &lt;code>sys_openat&lt;/code>, &lt;code>sys_connect&lt;/code>, &lt;code>tcp_connect&lt;/code>, &lt;code>do_mount&lt;/code>, &lt;code>commit_creds&lt;/code>. It is the most versatile hook and the one used 80% of the time.&lt;/li>
&lt;li>&lt;code>tracepoints&lt;/code>: hook static tracepoints compiled into the kernel. More stable across kernel versions than kprobes (they do not depend on function names that can change). Examples: &lt;code>syscalls/sys_enter_openat&lt;/code>, &lt;code>sched/sched_process_exec&lt;/code>.&lt;/li>
&lt;li>&lt;code>uprobes&lt;/code>: hook functions in userspace libraries or binaries. They serve to observe runtime primitives such as libssl functions, libc, the Go runtime, the JVM.&lt;/li>
&lt;li>&lt;strong>USDT &lt;code>tracepoints&lt;/code>&lt;/strong> (User Statically Defined Tracepoints): static tracepoints defined in userspace binaries (like those MySQL, PostgreSQL and OpenJDK expose). Useful for application observability.&lt;/li>
&lt;li>&lt;code>lsmHooks&lt;/code> (LSM, Linux Security Module): hooks of the LSM subsystem, where SELinux/AppArmor plug in. They allow security policies very similar to traditional MAC but programmable with eBPF. Example: &lt;code>file_open&lt;/code>, &lt;code>inode_unlink&lt;/code>, &lt;code>socket_bind&lt;/code>.&lt;/li>
&lt;/ul>
&lt;h3 id="arguments">Arguments&lt;/h3>
&lt;p>Every hook can read the arguments of the function it is attached to. The supported types cover the primitives (&lt;code>int&lt;/code>, &lt;code>uint64&lt;/code>, &lt;code>bool&lt;/code>, &lt;code>string&lt;/code>, &lt;code>char_buf&lt;/code>) and higher abstractions (&lt;code>file&lt;/code>, &lt;code>path&lt;/code>, &lt;code>sock&lt;/code>, &lt;code>linux_binprm&lt;/code>, &lt;code>capability&lt;/code>, &lt;code>bpf_attr&lt;/code>, &lt;code>cred&lt;/code>). The high-level types are pointers to kernel structures that Tetragon knows how to parse; instead of having to read an offset, you write &lt;code>type: file&lt;/code> and Tetragon gives you the full path of the descriptor&amp;rsquo;s file.&lt;/p>
&lt;p>There is an important capability detail: on kernels ≥ 5.4, Tetragon can read &lt;strong>up to 327,360 bytes&lt;/strong> of an argument if the large buffers flag is enabled. That is the difference between being able to audit &lt;code>execve&lt;/code> with all of its long argv complete versus truncating them at 256 bytes and losing context.&lt;/p>
&lt;h3 id="selectors-filtering-in-the-kernel">Selectors: filtering in the kernel&lt;/h3>
&lt;p>Selectors are what make Tetragon cheap. Without them, every syscall on the node would fire an event that would travel kernel → ring buffer → agent → processed → filtered → discarded. With selectors, the filtering happens &lt;strong>inside the eBPF program itself, in the kernel&lt;/strong>, and only the events that matter reach userspace.&lt;/p>
&lt;p>The available selectors include:&lt;/p>
&lt;ul>
&lt;li>&lt;code>matchArgs&lt;/code>: filters by an argument&amp;rsquo;s value. Operators: &lt;code>Equal&lt;/code>, &lt;code>NotEqual&lt;/code>, &lt;code>Prefix&lt;/code>, &lt;code>Postfix&lt;/code>, &lt;code>GreaterThan&lt;/code>, &lt;code>LessThan&lt;/code>, &lt;code>Mask&lt;/code>, &lt;code>SPort&lt;/code> (source port), &lt;code>DPort&lt;/code> (dest port), &lt;code>Family&lt;/code> (AF_INET vs AF_INET6), &lt;code>State&lt;/code> (socket state).&lt;/li>
&lt;li>&lt;code>matchPIDs&lt;/code>: filters by PID; useful for targeted observation.&lt;/li>
&lt;li>&lt;code>matchBinaries&lt;/code>: filters by the binary executing the syscall (absolute path), with &lt;code>Operator: In&lt;/code>, &lt;code>NotIn&lt;/code>, &lt;code>Prefix&lt;/code>. Essential for avoiding noise from legitimate system processes.&lt;/li>
&lt;li>&lt;code>matchNamespaces&lt;/code>: filters by Linux namespace (Pid, Mnt, Net, Ipc, Cgroup, User). It allows policies specific to processes in containers versus the host.&lt;/li>
&lt;li>&lt;code>matchCapabilities&lt;/code>: filters by the process&amp;rsquo;s effective capabilities. Block actions requiring &lt;code>CAP_SYS_ADMIN&lt;/code> that run in pods that should not have them.&lt;/li>
&lt;li>&lt;code>matchNamespaceChanges&lt;/code>: detects namespace changes (typical of container escape).&lt;/li>
&lt;li>&lt;code>matchCapabilityChanges&lt;/code>: detects capability changes (privilege escalation).&lt;/li>
&lt;li>&lt;code>matchActions&lt;/code>: the actions executed when all the preceding matchers hit.&lt;/li>
&lt;/ul>
&lt;h3 id="actions-from-a-simple-post-to-sigkill">Actions: from a simple Post to Sigkill&lt;/h3>
&lt;p>When a selector matches, an &lt;code>action&lt;/code> is executed. Tetragon defines several:&lt;/p>
&lt;ul>
&lt;li>&lt;code>Post&lt;/code>: emits an event to userspace (the observability case). It supports &lt;code>rateLimit&lt;/code> to avoid flooding the agent if the condition fires a thousand times per second. The syntax accepts &lt;code>5&lt;/code> for 5 seconds, &lt;code>5m&lt;/code> for 5 minutes, &lt;code>1h&lt;/code> for 1 hour.&lt;/li>
&lt;li>&lt;code>Sigkill&lt;/code>: sends &lt;code>SIGKILL&lt;/code> to the offending process from inside the kernel, &lt;strong>before the syscall completes&lt;/strong>. This is the only thing that guarantees enforcement without a race.&lt;/li>
&lt;li>&lt;code>Override&lt;/code>: overwrites the syscall&amp;rsquo;s return value. Useful for making the process believe the syscall failed (&lt;code>Override -EPERM&lt;/code>) without killing it. A better experience for apps that can handle errors; worse for apps that assume success.&lt;/li>
&lt;li>&lt;code>Signal&lt;/code>: sends any arbitrary signal (not just &lt;code>SIGKILL&lt;/code>).&lt;/li>
&lt;li>&lt;code>NoPost&lt;/code>: does not emit an event, useful when combined with another selector that does emit and you only want the action without duplicated telemetry.&lt;/li>
&lt;li>&lt;strong>&lt;code>FollowFD&lt;/code> and &lt;code>UnfollowFD&lt;/code>&lt;/strong>: mark a file descriptor to follow its lifecycle and enrich subsequent events with the original path. Useful for auditing &amp;ldquo;which process read this file after opening it&amp;rdquo;.&lt;/li>
&lt;li>&lt;code>TrackSock&lt;/code> and &lt;code>UntrackSock&lt;/code>: the same for sockets.&lt;/li>
&lt;li>&lt;strong>&lt;code>GetUrl&lt;/code> and &lt;code>DnsLookup&lt;/code>&lt;/strong>: make HTTP requests or DNS resolutions from the kernel. Designed for integrations with external systems (security webhooks, IP reputation lookups).&lt;/li>
&lt;li>&lt;code>NotifyEnforcer&lt;/code> and &lt;code>CleanupEnforcerNotification&lt;/code>: communication with Tetragon&amp;rsquo;s enforcement subsystem for complex actions.&lt;/li>
&lt;/ul>
&lt;h2 id="modes-detection-vs-enforcement">Modes: detection vs enforcement&lt;/h2>
&lt;p>A policy can be declared in one of two explicit modes:&lt;/p>
&lt;ul>
&lt;li>&lt;code>enforce&lt;/code>: enforcement actions (&lt;code>Sigkill&lt;/code>, &lt;code>Override&lt;/code>, &lt;code>Signal&lt;/code>) are active. This is production.&lt;/li>
&lt;li>&lt;code>monitoring&lt;/code>: enforcement actions are ignored; only &lt;code>Post&lt;/code> events are emitted. This is the &amp;ldquo;let us see what would happen if this were switched on&amp;rdquo; mode, critical for testing policies without breaking applications.&lt;/li>
&lt;/ul>
&lt;p>Control is done with the &lt;code>spec.options[].name: policy-mode&lt;/code> field and &lt;code>value: monitoring&lt;/code> or &lt;code>enforce&lt;/code>. It is the best practice: start in &lt;code>monitoring&lt;/code>, collect events for days, tune the selectors until no false positives come out, and only then switch to &lt;code>enforce&lt;/code>.&lt;/p>
&lt;h2 id="full-example-blocking-writes-to-etcpasswd-in-the-prod-namespace">Full example: blocking writes to &lt;code>/etc/passwd&lt;/code> in the prod namespace&lt;/h2>
&lt;p>A realistic policy, commented line by line:&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">apiVersion&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">cilium.io/v1alpha1&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">kind&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">TracingPolicyNamespaced&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">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">block-passwd-write&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">namespace&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">prod&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">spec&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">kprobes&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">call&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;fd_install&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">syscall&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kc">false&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c"># kernel function, not a syscall&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">args&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">index&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">0&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">int &lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c"># file descriptor&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">index&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">1&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="s2">&amp;#34;file&amp;#34;&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c"># struct file*&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">selectors&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">matchArgs&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">index&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">1&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">operator&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;Equal&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">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="s2">&amp;#34;/etc/passwd&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">matchActions&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">action&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">Sigkill &lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c"># kills the process&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">rateLimit&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;1m&amp;#34;&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c"># max once per minute&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">options&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">policy-mode&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">value&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">enforce &lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c"># enforcement mode active&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;code>fd_install&lt;/code> runs every time a process obtains a new file descriptor; the second argument is the file&amp;rsquo;s &lt;code>file&lt;/code> struct. Tetragon knows how to resolve it to its absolute path. The &lt;code>matchArgs&lt;/code> compares that path with &lt;code>/etc/passwd&lt;/code>. If it matches, &lt;code>Sigkill&lt;/code> kills the process before the descriptor even becomes usable. &lt;code>rateLimit: 1m&lt;/code> stops the agent from saturating if a malicious application tries it in a loop.&lt;/p>
&lt;h2 id="common-use-cases">Common use cases&lt;/h2>
&lt;p>Now to real use. These are the six cases that show up in any serious Tetragon deployment in 2026.&lt;/p>
&lt;h3 id="1-execution-auditing-execve">1. Execution auditing (&lt;code>execve&lt;/code>)&lt;/h3>
&lt;p>The most basic use case and, even so, the most valuable. Which binaries are running in each pod? In a container that is supposed to run only &lt;code>nginx&lt;/code>, suddenly seeing an &lt;code>sh&lt;/code> or a &lt;code>wget&lt;/code> is almost always a red flag.&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">apiVersion&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">cilium.io/v1alpha1&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">kind&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">TracingPolicy&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">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">audit-execve&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">spec&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">tracepoints&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">subsystem&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;sched&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">event&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;sched_process_exec&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">args&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">index&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">4&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">linux_binprm &lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c"># struct linux_binprm*&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">selectors&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">matchActions&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">action&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">Post &lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c"># events only, no enforcement&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>With no filters: every &lt;code>execve&lt;/code> in the cluster generates an event. With K8s metadata, the event includes pod, namespace, container, image, labels. You turn it into a stream of events towards your SIEM and set up rules: &amp;ldquo;alert if I see &lt;code>sh&lt;/code>, &lt;code>bash&lt;/code>, &lt;code>nc&lt;/code>, &lt;code>curl&lt;/code>, &lt;code>wget&lt;/code> or &lt;code>python&lt;/code> running in any pod of the &lt;code>prod-api&lt;/code> namespace&amp;rdquo;.&lt;/p>
&lt;p>An enforcement variant: instead of &lt;code>Post&lt;/code>, use &lt;code>matchBinaries&lt;/code> with &lt;code>Operator: NotIn&lt;/code> and a whitelist, plus &lt;code>Sigkill&lt;/code> if the binary is not on the list. A very rigid box, but effective in pods that are &amp;ldquo;single-binary&amp;rdquo; (like a Go microservice).&lt;/p>
&lt;h3 id="2-sensitive-file-access">2. Sensitive file access&lt;/h3>
&lt;p>Detecting (or blocking) reads and writes on critical files: &lt;code>/etc/shadow&lt;/code>, &lt;code>/etc/kubernetes/&lt;/code>, Secret mounts, &lt;code>/var/run/docker.sock&lt;/code>, &lt;code>/proc/*/cmdline&lt;/code>.&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">apiVersion&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">cilium.io/v1alpha1&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">kind&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">TracingPolicy&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">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">sensitive-file-access&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">spec&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">kprobes&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">call&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;security_file_open&amp;#34;&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c"># LSM-ish via kprobe&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">syscall&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kc">false&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">args&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">index&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">0&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="s2">&amp;#34;file&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">selectors&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">matchArgs&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">index&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">0&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">operator&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;Prefix&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">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="s2">&amp;#34;/etc/shadow&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="s2">&amp;#34;/var/run/secrets/&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="s2">&amp;#34;/var/run/docker.sock&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">matchBinaries&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">operator&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;NotIn&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">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="s2">&amp;#34;/usr/bin/kubelet&amp;#34;&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c"># legitimate kubelet access&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">matchActions&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">action&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">Post&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>The &lt;code>matchBinaries: NotIn&lt;/code> is important: kubelet and other legitimate node agents access these paths constantly and would generate noise. We filter those out in the kernel.&lt;/p>
&lt;p>In enforcement: swap &lt;code>Post&lt;/code> for &lt;code>Override&lt;/code> with &lt;code>argError: -1&lt;/code> (&lt;code>EPERM&lt;/code>), so that the open fails but the offending process stays alive and produces the error so that tracing tools pick it up.&lt;/p>
&lt;h3 id="3-unauthorised-outbound-network-connections">3. Unauthorised outbound network connections&lt;/h3>
&lt;p>Detecting outbound connections to destinations outside the corporate range. Useful for spotting data exfiltration or malware command-and-control.&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">apiVersion&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">cilium.io/v1alpha1&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">kind&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">TracingPolicyNamespaced&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">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">block-external-egress&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">namespace&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">prod&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">spec&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">kprobes&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">call&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;tcp_connect&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">syscall&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kc">false&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">args&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">index&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">0&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="s2">&amp;#34;sock&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">selectors&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">matchArgs&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">index&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">0&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">operator&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;NotDAddr&amp;#34;&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c"># destination NOT in these CIDRs&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">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="s2">&amp;#34;10.0.0.0/8&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="s2">&amp;#34;192.168.0.0/16&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="s2">&amp;#34;172.16.0.0/12&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">matchActions&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">action&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">Sigkill&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">options&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">policy-mode&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">value&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">enforce&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>This kills any attempt at a TCP connection to an IP that is not in the corporate CIDRs, in the &lt;code>prod&lt;/code> namespace. Cilium already does this with NetworkPolicy, but Tetragon has two complementary advantages:&lt;/p>
&lt;ul>
&lt;li>It gives you the process that attempted the connection, not just &amp;ldquo;pod X tried to connect to Y&amp;rdquo;.&lt;/li>
&lt;li>It also works for exotic protocols where NetworkPolicy is less expressive.&lt;/li>
&lt;/ul>
&lt;h3 id="4-container-escape-detection">4. Container escape detection&lt;/h3>
&lt;p>Container escape is the operational nightmare: a process inside a container manages to break the isolation (via a kernel exploit, a badly set capability, a misconfigured &lt;code>hostPath&lt;/code> mount) and gain access to the host. Three typical signals:&lt;/p>
&lt;ul>
&lt;li>A namespace change on the process (it leaves the container&amp;rsquo;s &lt;code>pid&lt;/code> namespace).&lt;/li>
&lt;li>&lt;strong>&lt;code>setns&lt;/code> or &lt;code>unshare&lt;/code>&lt;/strong> in non-init processes.&lt;/li>
&lt;li>&lt;strong>Access to &lt;code>/proc/1/root&lt;/code> or &lt;code>/dev/&lt;/code>&lt;/strong> from a container.&lt;/li>
&lt;/ul>
&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">apiVersion&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">cilium.io/v1alpha1&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">kind&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">TracingPolicy&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">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">detect-container-escape&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">spec&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">kprobes&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">call&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;__x64_sys_setns&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">syscall&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">args&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">index&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">0&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">int&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">index&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">1&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">int&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">selectors&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">matchNamespaces&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">namespace&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">Pid&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">operator&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">NotIn&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">values&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;host_ns&amp;#34;&lt;/span>&lt;span class="p">]&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c"># only processes NOT in the host pid namespace&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">matchActions&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">action&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">Sigkill&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">call&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;__x64_sys_unshare&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">syscall&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">args&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">index&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">0&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">int&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">selectors&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">matchNamespaceChanges&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">unshare&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">matchActions&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">action&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">Post&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;code>__x64_sys_setns&lt;/code> targeting the host namespace from a process in a container is practically always malicious (legitimate containers do not need this at runtime).&lt;/p>
&lt;h3 id="5-cryptomining">5. Cryptomining&lt;/h3>
&lt;p>Mining processes have fairly recognisable profiles:&lt;/p>
&lt;ul>
&lt;li>Processes with names like &lt;code>xmrig&lt;/code>, &lt;code>minerd&lt;/code>, &lt;code>cgminer&lt;/code>, or legitimate processes such as &lt;code>python&lt;/code> running CPU-intensive scripts.&lt;/li>
&lt;li>Outbound connections to known mining pools (a public list of IPs and domains).&lt;/li>
&lt;li>Anomalous use of &lt;code>/dev/cpu_dma_latency&lt;/code> to avoid throttling.&lt;/li>
&lt;/ul>
&lt;p>A combined policy:&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">apiVersion&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">cilium.io/v1alpha1&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">kind&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">TracingPolicy&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">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">detect-cryptomining&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">spec&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">tracepoints&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">subsystem&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;sched&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">event&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;sched_process_exec&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">args&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">index&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">4&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">linux_binprm&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">selectors&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">matchArgs&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">index&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">4&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">operator&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;Postfix&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">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="s2">&amp;#34;/xmrig&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="s2">&amp;#34;/minerd&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="s2">&amp;#34;/cgminer&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">matchActions&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">action&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">Sigkill&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">kprobes&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">call&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;tcp_connect&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">syscall&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kc">false&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">args&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">index&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">0&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="s2">&amp;#34;sock&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">selectors&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">matchArgs&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">index&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">0&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">operator&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;DPort&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">values&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;3333&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;5555&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;7777&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;14444&amp;#34;&lt;/span>&lt;span class="p">]&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c"># common pool ports&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">matchActions&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">action&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">Post &lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c"># log only&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>The double policy: kill binaries with the classic names (belt) and log connections to pool ports (braces), so you also get an alert when someone renames &lt;code>xmrig&lt;/code> to &lt;code>nginx-helper&lt;/code> or uses exotic ports.&lt;/p>
&lt;h3 id="6-detecting-rootkits-and-suspicious-kernel-modules">6. Detecting rootkits and suspicious kernel modules&lt;/h3>
&lt;p>Modern rootkits load kernel modules to patch functions (hide processes, hide network connections, hide files). Detecting them:&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">apiVersion&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">cilium.io/v1alpha1&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">kind&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">TracingPolicy&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">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">kernel-module-load&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">spec&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">kprobes&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">call&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;do_init_module&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">syscall&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kc">false&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">args&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">index&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">0&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="s2">&amp;#34;string&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">selectors&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">matchActions&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">action&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">Post&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">call&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;security_kernel_read_file&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">syscall&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kc">false&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">args&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">index&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">0&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="s2">&amp;#34;file&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">index&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">1&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">int&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">selectors&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">matchArgs&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">index&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">1&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">operator&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;Equal&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">values&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;READING_MODULE&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="nt">matchActions&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">action&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">Post&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>In a &amp;ldquo;well configured&amp;rdquo; Kubernetes cluster no new kernel modules get loaded at runtime; any event here is highly suspicious. Combine with enforcement on machines where modules should be fixed: &lt;code>Sigkill&lt;/code> for whoever tries to load one.&lt;/p>
&lt;h3 id="bonus-detecting-third-party-modification-of-ebpf-maps">Bonus: detecting third-party modification of eBPF maps&lt;/h3>
&lt;p>As a 2025-2026 trend: loading malicious eBPF programs to hide presence. Tetragon can observe the bpf syscall:&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">apiVersion&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">cilium.io/v1alpha1&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">kind&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">TracingPolicy&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">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">audit-bpf-syscalls&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">spec&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">kprobes&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">call&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;__x64_sys_bpf&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">syscall&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">args&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">index&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">0&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">int &lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c"># bpf cmd&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">index&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">1&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">bpf_attr&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">selectors&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">matchBinaries&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">operator&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;NotIn&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">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="s2">&amp;#34;/usr/bin/cilium-agent&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="s2">&amp;#34;/usr/bin/tetragon&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="s2">&amp;#34;/usr/bin/bpftool&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">matchActions&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">action&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">Post&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Any process that is &lt;strong>not&lt;/strong> one of the legitimate agents loading eBPF programs: you want to know about it.&lt;/p>
&lt;h2 id="comparison-with-falco">Comparison with Falco&lt;/h2>
&lt;p>&lt;a href="https://falco.org/">Falco&lt;/a> is the closest competitor: it is also runtime security for Kubernetes, also originally based on eBPF (and before that on kernel modules), also with declarative policies. Three years ago they were functionally similar. In 2026 the divergence is clear:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Dimension&lt;/th>
&lt;th>Tetragon&lt;/th>
&lt;th>Falco&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Philosophy&lt;/td>
&lt;td>Cilium-native, integrated&lt;/td>
&lt;td>Standalone, generic&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Filtering&lt;/td>
&lt;td>In the kernel (eBPF)&lt;/td>
&lt;td>Parsing in userspace&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Typical overhead&lt;/td>
&lt;td>&lt;strong>&amp;lt;1% CPU&lt;/strong>&lt;/td>
&lt;td>5-10% CPU&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Enforcement&lt;/td>
&lt;td>&lt;strong>Yes, in-kernel (Sigkill, Override)&lt;/strong>&lt;/td>
&lt;td>Not native (depends on plugins)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Race conditions&lt;/td>
&lt;td>No (action atomic with the syscall)&lt;/td>
&lt;td>Yes, in enforcement via plugins&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>False positive rate&lt;/td>
&lt;td>Low (K8s context in the kernel)&lt;/td>
&lt;td>Higher (parsing afterwards)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Detection latency&lt;/td>
&lt;td>5-26 ms&lt;/td>
&lt;td>~10 ms (more constant)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Ecosystem maturity&lt;/td>
&lt;td>Young, growing&lt;/td>
&lt;td>Mature, plenty of material&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Community&lt;/td>
&lt;td>Cilium / CNCF Incubating&lt;/td>
&lt;td>CNCF Graduated&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Integrations&lt;/td>
&lt;td>Hubble native&lt;/td>
&lt;td>Falcosidekick, many&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>CRDs per policy&lt;/td>
&lt;td>TracingPolicy / Namespaced&lt;/td>
&lt;td>No CRDs; rules in YAML&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>&lt;strong>When to choose each one&lt;/strong>:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Tetragon&lt;/strong> if you already use Cilium, if you need enforcement in the kernel (not detection-only), if overhead matters to you (workloads with many syscalls), and if you value the Hubble integration. Container escape and cryptomining detection is where its advantage over Falco shows up most.&lt;/li>
&lt;li>&lt;strong>Falco&lt;/strong> if you want a tool independent of the CNI, if you need the catalogue of ready-made rules and the wide community, if your cluster is not Cilium, or if the integration with SIEMs and ready-made notifiers (Falcosidekick) saves you work.&lt;/li>
&lt;li>&lt;strong>Both&lt;/strong> if the organisation is large: Falco for breadth of detection, Tetragon for surgical enforcement on critical workloads. That is what you see most in companies that have spent years with Falco and add Tetragon for specific cases.&lt;/li>
&lt;/ul>
&lt;h2 id="hubble--tetragon-unified-observability">Hubble + Tetragon: unified observability&lt;/h2>
&lt;p>&lt;a href="https://docs.cilium.io/en/stable/observability/hubble/">Hubble&lt;/a> is Cilium&amp;rsquo;s traffic observability component: it shows L3-L7 flow logs with zero impact on latency. Tetragon exposes its events over gRPC with the same format and vocabulary as Hubble, which allows you to:&lt;/p>
&lt;ul>
&lt;li>See them in the same UI (Hubble UI shows Tetragon events as one more &amp;ldquo;layer&amp;rdquo;).&lt;/li>
&lt;li>Correlate network events (Hubble) with process events (Tetragon) on the same timeline.&lt;/li>
&lt;li>Export them together to Loki/Tempo/SIEM as a single stream.&lt;/li>
&lt;/ul>
&lt;p>The key synergy: Hubble tells you &amp;ldquo;this pod made a TCP connection to 1.2.3.4:80&amp;rdquo;. Tetragon tells you &amp;ldquo;this pod ran &lt;code>curl 1.2.3.4&lt;/code> from a &lt;code>bash&lt;/code> binary launched by &lt;code>pid 1234&lt;/code>&amp;rdquo;. Together they give you the full story.&lt;/p>
&lt;h2 id="deployment-and-operation">Deployment and operation&lt;/h2>
&lt;h3 id="helm">Helm&lt;/h3>
&lt;p>The canonical install with Helm:&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">helm repo add cilium https://helm.cilium.io
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">helm install tetragon cilium/tetragon &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> --namespace kube-system &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> --set tetragon.exportFilename&lt;span class="o">=&lt;/span>/var/log/tetragon/tetragon.log &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> --set tetragon.exportFileMaxSizeMB&lt;span class="o">=&lt;/span>&lt;span class="m">50&lt;/span> &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> --set tetragon.exportFileRotationInterval&lt;span class="o">=&lt;/span>24h
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Tetragon deploys its &lt;code>DaemonSet&lt;/code>, its CRDs and a service for Hubble. By default, it exposes the events on the agent pod&amp;rsquo;s &lt;code>stdout&lt;/code> (any cluster log aggregator picks them up).&lt;/p>
&lt;h3 id="the-tetra-cli">The &lt;code>tetra&lt;/code> CLI&lt;/h3>
&lt;p>Tetragon ships a CLI called &lt;code>tetra&lt;/code> for interactive investigation:&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"># real-time stream of the node&amp;#39;s events&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">tetra getevents -o compact --pods &amp;lt;pod-name&amp;gt;
&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"># structured JSON events to process with jq&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">tetra getevents -o json --since 5m --namespace prod
&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"># view loaded policies&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">tetra tracingpolicy list
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>It is the best tool for debugging policies in &lt;code>monitoring&lt;/code> before moving them to &lt;code>enforce&lt;/code>.&lt;/p>
&lt;h3 id="exporting-to-a-siem">Exporting to a SIEM&lt;/h3>
&lt;p>Three usual routes:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>stdout + log aggregator&lt;/strong>: the agent writes JSON to stdout, Fluent Bit/Vector picks it up and sends it to Splunk/Datadog/Elastic. Simple, works with any logging infrastructure.&lt;/li>
&lt;li>&lt;strong>gRPC streaming&lt;/strong>: for low-latency integrations. A gRPC consumer of your own or Hubble Relay.&lt;/li>
&lt;li>&lt;strong>File + rotation&lt;/strong>: for air-gapped environments or regulatory audits that require persistent logs with controlled rotation.&lt;/li>
&lt;/ul>
&lt;h3 id="performance">Performance&lt;/h3>
&lt;p>Published benchmarks consistently place Tetragon at &lt;strong>&amp;lt;1% of the node&amp;rsquo;s CPU&lt;/strong> under real workloads, compared with &lt;strong>5-10% for Falco&lt;/strong> under the same workloads. The reason is the architectural separation: Tetragon filters in the kernel and only carries the events that actually matter to userspace; Falco carries every syscall to userspace and filters there. On clusters with thousands of pods making hundreds of thousands of syscalls per second, the difference shows up on the bill.&lt;/p>
&lt;h2 id="common-operational-traps">Common operational traps&lt;/h2>
&lt;h3 id="permanent-monitoring">Permanent &lt;code>monitoring&lt;/code>&lt;/h3>
&lt;p>The biggest trap is &lt;strong>never reaching &lt;code>enforce&lt;/code>&lt;/strong>: starting well with policies in monitoring, collecting events, tuning selectors, and then never switching. The result: you have detection without prevention, exactly what Falco gave you without paying Tetragon&amp;rsquo;s complexity. If you are going to use Tetragon, plan the road to enforce for the critical policies.&lt;/p>
&lt;h3 id="selectors-that-are-too-lax">Selectors that are too lax&lt;/h3>
&lt;p>A policy with a single &lt;code>matchActions: Post&lt;/code> and no specific selectors generates events for &lt;strong>every&lt;/strong> syscall of the chosen hook. On a serious node that means &lt;strong>tens of thousands per second&lt;/strong>, which fill logs, saturate exporters and hide the signal in the noise. Always start with strict filters (&lt;code>matchBinaries&lt;/code>, &lt;code>matchNamespaces&lt;/code>, &lt;code>matchPIDs&lt;/code>) and open up once you know what you are looking for.&lt;/p>
&lt;h3 id="a-kernel-that-is-too-old">A kernel that is too old&lt;/h3>
&lt;p>Tetragon needs modern eBPF features. Kernels &amp;lt; 5.4 do not have the large buffer support (needed for &lt;code>execve&lt;/code> with complete argv). Kernels &amp;lt; 5.10 do not have many of the LSM hooks. &lt;strong>Kernel 5.15+ is the recommended minimum for production&lt;/strong> and 6.1+ to have every feature.&lt;/p>
&lt;h3 id="hooks-on-renamed-kernel-functions">Hooks on renamed kernel functions&lt;/h3>
&lt;p>kprobes are tied to kernel function names that &lt;strong>can change between versions&lt;/strong>. A policy that uses &lt;code>__x64_sys_setns&lt;/code> can fail silently on a kernel where the function is called &lt;code>__do_sys_setns&lt;/code>. Solutions: use static tracepoints where they are available (more stable), or keep alternative policies with several &lt;code>call&lt;/code> entries for compatibility.&lt;/p>
&lt;h3 id="sigkill-in-critical-namespaces">&lt;code>Sigkill&lt;/code> in critical namespaces&lt;/h3>
&lt;p>Applying &lt;code>Sigkill&lt;/code> to processes in &lt;code>kube-system&lt;/code> or &lt;code>cilium-system&lt;/code> can break the cluster. Enforcement policies must &lt;strong>explicitly exclude&lt;/strong> the platform namespaces with &lt;code>matchNamespaces&lt;/code> Operator: &lt;code>NotIn&lt;/code>, or limit the scope with &lt;code>TracingPolicyNamespaced&lt;/code> to make sure they do not act on systems they should not.&lt;/p>
&lt;h3 id="missing-ratelimit">Missing &lt;code>rateLimit&lt;/code>&lt;/h3>
&lt;p>A policy without a rateLimit on &lt;code>Post&lt;/code> can suffer a catastrophic fan-out if the condition is met millions of times in an instant (typical in attack loops or application bugs). The agent saturates, events are lost, logs overflow. &lt;strong>Always put a sensible &lt;code>rateLimit&lt;/code> on detection policies&lt;/strong>, especially on high-frequency hooks such as &lt;code>tcp_connect&lt;/code> or &lt;code>execve&lt;/code>.&lt;/p>
&lt;h2 id="what-we-have-not-covered-upcoming-articles">What we have not covered (upcoming articles)&lt;/h2>
&lt;ul>
&lt;li>&lt;strong>eBPF LSM hooks&lt;/strong> in depth: how they relate to SELinux/AppArmor and when Tetragon is the right tool versus classic MAC.&lt;/li>
&lt;li>&lt;strong>Hubble UI with a Tetragon overlay&lt;/strong>: configuring the UI to show process observability and network observability on the same timeline.&lt;/li>
&lt;li>&lt;strong>Integration with OPA/Kyverno&lt;/strong>: how Tetragon complements admission policy engines (Kyverno validates at admission; Tetragon validates at runtime).&lt;/li>
&lt;li>&lt;strong>Forensics with eBPF&lt;/strong>: combining Tetragon with tools such as Beyla or OpenTelemetry to trace the full chain of an incident from the initial connection to the final syscall.&lt;/li>
&lt;/ul>
&lt;h2 id="references">References&lt;/h2>
&lt;p>Official documentation (May 2026):&lt;/p>
&lt;ul>
&lt;li>&lt;a href="https://tetragon.io/">Tetragon — official site&lt;/a> — entry point.&lt;/li>
&lt;li>&lt;a href="https://tetragon.io/docs/concepts/tracing-policy/">Tetragon docs — Tracing Policy&lt;/a> — conceptual reference.&lt;/li>
&lt;li>&lt;a href="https://tetragon.io/docs/concepts/tracing-policy/hooks/">Tetragon docs — Hook points&lt;/a> — kprobes, tracepoints, uprobes, LSM, USDT.&lt;/li>
&lt;li>&lt;a href="https://tetragon.io/docs/concepts/tracing-policy/selectors/">Tetragon docs — Selectors&lt;/a> — full filter reference.&lt;/li>
&lt;li>&lt;a href="https://tetragon.io/docs/concepts/tracing-policy/mode/">Tetragon docs — Enforcement Mode&lt;/a>.&lt;/li>
&lt;li>&lt;a href="https://tetragon.io/docs/concepts/tracing-policy/k8s-filtering/">Tetragon docs — Kubernetes Identity Aware Policies&lt;/a> — &lt;code>TracingPolicyNamespaced&lt;/code>.&lt;/li>
&lt;li>&lt;a href="https://github.com/cilium/tetragon">Tetragon GitHub&lt;/a>.&lt;/li>
&lt;/ul>
&lt;p>Comparisons and analysis:&lt;/p>
&lt;ul>
&lt;li>&lt;a href="https://www.scitepress.org/Papers/2025/142727/142727.pdf">Comparative Analysis of eBPF-Based Runtime Security Monitoring (SciTePress paper 2025)&lt;/a> — a benchmark with independent numbers.&lt;/li>
&lt;li>&lt;a href="https://www.armosec.io/blog/best-ebpf-security-solutions-runtime-protection/">Best eBPF Security Solutions for Kubernetes (ARMO, 2026)&lt;/a> — Falco vs Tetragon vs KubeArmor comparison.&lt;/li>
&lt;li>&lt;a href="https://medium.com/@mughal.asim/falco-vs-tetragon-a-runtime-security-showdown-for-kubernetes-a0e9fb9f30a0">Falco vs. Tetragon (Asim Mirza, Medium)&lt;/a> — analysis with use cases.&lt;/li>
&lt;li>&lt;a href="https://asecurityengineer.com/posts/deep-dive-into-tetragon/">Deep Dive into Tetragon (A Security Engineer)&lt;/a> — a walk through the inside of the agent.&lt;/li>
&lt;li>&lt;a href="https://medium.com/@mughal.asim/tetragon-series-part-2-enforcing-sensitive-file-access-with-a-namespaced-tracingpolicy-3c2f617ec912">Tetragon Series, Part 2: Enforcing Sensitive File Access (Medium)&lt;/a>.&lt;/li>
&lt;/ul>
&lt;p>Ecosystem:&lt;/p>
&lt;ul>
&lt;li>&lt;a href="https://docs.cilium.io/en/stable/observability/hubble/">Cilium Hubble — network observability&lt;/a>.&lt;/li>
&lt;li>&lt;a href="https://falco.org/">Falco — official site&lt;/a> — the other big name in the field.&lt;/li>
&lt;li>&lt;a href="https://kubearmor.io/">KubeArmor&lt;/a> — the third option, with AppArmor + eBPF.&lt;/li>
&lt;/ul>
&lt;p>Cross-references:&lt;/p>
&lt;ul>
&lt;li>Part 1 of the series: &lt;a href="https://blog.lo0.es/en/posts/ebpf-zero-to-cilium-kernel-tcp-ip-bypass/">eBPF from zero to Cilium: how the kernel learned to skip its own TCP/IP stack&lt;/a> — the eBPF fundamentals we take as read here.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/kubernetes-cilium-bgp-services-without-ingress/">Kubernetes with Cilium BGP: services reachable without Ingress&lt;/a> — the starting point of the Cilium ecosystem on this blog.&lt;/li>
&lt;/ul></description></item></channel></rss>