<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom"><channel><title>Langfuse on lo0 — Tech Blog</title><link>https://blog.lo0.es/en/tags/langfuse/</link><description>Recent content in Langfuse on lo0 — Tech Blog</description><generator>Hugo -- gohugo.io</generator><language>en</language><lastBuildDate>Tue, 15 Sep 2026 09:30:00 +0200</lastBuildDate><atom:link href="https://blog.lo0.es/en/tags/langfuse/index.xml" rel="self" type="application/rss+xml"/><item><title>Real ClickHouse capacity in Langfuse v4: 2,782 bytes per observation, and why almost half of that is not data</title><link>https://blog.lo0.es/en/posts/clickhouse-capacity-langfuse-v4-bytes-per-observation/</link><pubDate>Tue, 15 Sep 2026 09:30:00 +0200</pubDate><guid>https://blog.lo0.es/en/posts/clickhouse-capacity-langfuse-v4-bytes-per-observation/</guid><description>&lt;blockquote>
&lt;p>Sixth article in the series on operating self-hosted Langfuse v4. The previous five cover &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/">putting LangGraph in front&lt;/a>, &lt;a href="https://blog.lo0.es/en/posts/langfuse-v4-real-agents-odr-gptr/">two well-known agents instrumented&lt;/a>, &lt;a href="https://blog.lo0.es/en/posts/langfuse-v4-migrating-with-no-window/">migrating with no window&lt;/a> and &lt;a href="https://blog.lo0.es/en/posts/langfuse-v4-worker-queues/">the worker queues&lt;/a>. This one measures what all of that takes up. Benchmark run on 15 September 2026 against the event schema of version 4.36.0, identical to that of 4.35.0.&lt;/p>
&lt;/blockquote>
&lt;h2 id="tldr">TL;DR&lt;/h2>
&lt;p>&lt;strong>Langfuse publishes no capacity figure at all.&lt;/strong> Not bytes per row, not events per second, not expected size. I searched their code with every reasonable combination and there is nothing. The only quantified thing is the commercial quotas of their managed service, which say nothing about disk.&lt;/p>
&lt;p>&lt;strong>One observation takes 2,782 bytes of disk.&lt;/strong> Split across the two event tables: 2,347 in the full table and 435 in the listings one. Measured over two hundred thousand observations shaped the way a real agent produces them.&lt;/p>
&lt;p>&lt;strong>41.8 % of the full table&amp;rsquo;s disk is not data, it is indexes.&lt;/strong> Four full-text indexes add up to 187 MiB against 260 MiB of data. The one covering the input costs 523 bytes per observation, almost three quarters of what the column it indexes costs.&lt;/p>
&lt;p>&lt;strong>The materialized view trims the text eight times over and saves eighteen per cent of disk.&lt;/strong> Truncating input and output to two hundred characters leaves the text at 64 MiB of the original 540, and even so the second table adds 18.5 % on top of the first. What survives the trimming is the seventy-eight-column skeleton and a metadata field that is truncated element by element, not as a whole.&lt;/p>
&lt;p>&lt;strong>In the listings table, metadata costs more than input and output together.&lt;/strong> 132.6 bytes per observation against 101.8. And its full-text index adds another 111.9, almost as much as the column.&lt;/p>
&lt;p>&lt;strong>A compression ratio quoted without saying what was compressed is worth nothing.&lt;/strong> My first pass, with repeated text, gave 38 times. With text of an entropy close to that of natural language it gives 3.68. Same schema, same indexes, same version.&lt;/p>
&lt;p>&lt;strong>Generations are 11 % of the observations and 65 % of the bytes.&lt;/strong> Their mean input is 15,655 bytes and the 95th percentile is 55,205, because each one reserialises the full history. It is the quadratic law from the second article in the series, landed on disk.&lt;/p>
&lt;p>&lt;strong>On deletion, the disk does not go down.&lt;/strong> A delete stops showing the rows and leaves the space occupied. A second step is needed, applying the deleted mask, and the cron that does it &lt;strong>ships disabled&lt;/strong>, just like the retention one. On top of that the cleaner works table by table, so the second table keeps the rows the first one no longer shows.&lt;/p>
&lt;p>&lt;strong>And automatic retention is a paid feature when self-hosted.&lt;/strong> The &lt;code>data-retention&lt;/code> entitlement is not in the open edition. Without it, the event tables grow without limit: they carry no expiry clause.&lt;/p>
&lt;h2 id="you-are-here-the-sizing-you-cannot-do-by-reading">You are here: the sizing you cannot do by reading&lt;/h2>
&lt;p>The previous five articles describe how version 4 works and how to put it to work. The moment arrives to ask for disk, and there the series runs out of sources.&lt;/p>
&lt;p>The official documentation gives a container sizing: two CPUs and eight gibibytes for ClickHouse, in Kubernetes eight of request and sixteen of limit, a hundred gibibytes of disk and three minimum replicas. It is a reasonable starting point and it does not answer the only question that matters, which is how long that disk lasts.&lt;/p>
&lt;p>I went looking for it in the code, which is what this series does. I searched for events per second, rows per observation, bytes per row, capacity, benchmark, sizing, across the whole repository and its documentation. &lt;strong>There is no figure at all.&lt;/strong> The closest thing is the ingestion quotas of their managed service, which are commercial API limits, and some working notes on a synthetic load generator that refer to the tables of the previous version and bring no results, only parameters.&lt;/p>
&lt;p>So the only honest way out was to measure it. This article is that measurement, with the whole method laid out so that anyone can repeat it and contrast it with their own load.&lt;/p>
&lt;h2 id="the-analogy-the-archive-and-the-index-card">The analogy: the archive and the index card&lt;/h2>
&lt;p>A municipal archive keeps case files. Each file has its folder with everything inside, and on top of that the archivist fills in an index card with the header data and the first lines of the subject, so that the file can be searched without pulling out the folder.&lt;/p>
&lt;p>Intuition says the cards take up nothing compared with the folders. And that is true if you look only at the paper. It stops being true when the archivist, in order to be able to search by any word, also builds a term file: a box with one card per distinct word appearing in any case file, pointing to where it occurs. That box grows with the vocabulary, not with the number of case files, and ends up taking almost as much room as the case files themselves.&lt;/p>
&lt;p>In this setup the folders are the full table, the cards are the listings table, and the term file is the full-text indexes. The article measures the three things separately, because whoever sizes by looking only at the folders falls short by almost half.&lt;/p>
&lt;h2 id="the-benchmark">The benchmark&lt;/h2>
&lt;p>I want this to be reproducible, so the method goes in whole.&lt;/p>
&lt;p>&lt;strong>The schema is the real one.&lt;/strong> I extracted the ClickHouse migration DDL from the repository, in its single-instance variant. There are not three migrations but six: the three that create the two tables and the materialized view, plus one that adds three ingestion attribution columns, another that adds an n-gram index over the metadata, and another that adds three evaluation columns and two indexes. The result is &lt;strong>78 columns per table, eleven skip indexes in the full table and fourteen in the listings one&lt;/strong>.&lt;/p>
&lt;p>I verified that this DDL is identical between the version I have pinned across the series and the current head of the repository: the diff of the migrations directory is empty.&lt;/p>
&lt;p>&lt;strong>The engine is ClickHouse 26.9.1.&lt;/strong> The minimum version 4 demands is 25.12, because of the text indexes the schema needs. I am using a newer version, and that is a difference from an installation that sticks to the minimum.&lt;/p>
&lt;p>&lt;strong>The data is synthetic and correctly shaped.&lt;/strong> The benchmark does not invent the rows: the columns and their typical values come from the worker function that builds the record. I write only into the full table, which is what the worker does, and let the materialized view populate the second one. That reproduces the real write amplification.&lt;/p>
&lt;p>The topology of each trace reproduces the law we measured in the second article of the series: four fixed observations plus five for each tool call. The number of calls per turn comes from a distribution with a low median and a long tail, capped at fifteen. Generations carry the full message history, which grows with the turn, because that is exactly what the instrumentation does and it is the origin of the quadratic growth in bytes.&lt;/p>
&lt;p>Result: 200,012 observations across 22,563 traces, that is &lt;strong>8.86 observations per trace&lt;/strong>.&lt;/p>
&lt;p>&lt;strong>And the entropy of the text is calibrated, which is the part that almost ruined the benchmark.&lt;/strong> My first pass used a repeated paragraph as filler. With that text, the full table compressed 38.1 times and the figures came out beautiful and false. A repeated lorem ipsum is the best possible input for a compressor. I redid the generator with a wide vocabulary sampled from a Zipf distribution and fifteen per cent high-entropy identifiers, calibrated to give the ratio that natural language mixed with identifiers gives.&lt;/p>
&lt;p>With that text, the same table compresses 3.68 times. &lt;strong>Ten times less.&lt;/strong> Any ClickHouse capacity figure that does not say what was compressed is noise, and mine would be too without this paragraph.&lt;/p>
&lt;h2 id="what-one-observation-takes-up">What one observation takes up&lt;/h2>
&lt;p>The two tables, after forcing the final merge:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>&lt;/th>
&lt;th>events_full&lt;/th>
&lt;th>events_core&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Rows&lt;/td>
&lt;td>200,012&lt;/td>
&lt;td>200,012&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Uncompressed&lt;/td>
&lt;td>957.87 MiB&lt;/td>
&lt;td>328.73 MiB&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Compressed&lt;/td>
&lt;td>260.29 MiB&lt;/td>
&lt;td>60.34 MiB&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Ratio&lt;/td>
&lt;td>3.68&lt;/td>
&lt;td>5.45&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>On disk&lt;/td>
&lt;td>447.68 MiB&lt;/td>
&lt;td>82.92 MiB&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Raw per observation&lt;/td>
&lt;td>5,022 B&lt;/td>
&lt;td>1,723 B&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Compressed per observation&lt;/td>
&lt;td>1,364.6 B&lt;/td>
&lt;td>316.3 B&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>On disk per observation&lt;/strong>&lt;/td>
&lt;td>&lt;strong>2,347 B&lt;/strong>&lt;/td>
&lt;td>&lt;strong>435 B&lt;/strong>&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>The figure to take away is the last row added up: &lt;strong>2,782 bytes of disk per observation&lt;/strong>, counting both tables, with one replica and no retention.&lt;/p>
&lt;p>And it is worth looking at the distance between the last two rows. The compressed data of the full table is 1,364.6 bytes per observation, but on disk it takes 2,347. The difference, 982 bytes per observation, is the next section.&lt;/p>
&lt;h2 id="where-the-bytes-go">Where the bytes go&lt;/h2>
&lt;p>Broken down by column, with what each one costs per observation:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Column&lt;/th>
&lt;th>events_full&lt;/th>
&lt;th>events_core&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>&lt;code>input&lt;/code>&lt;/td>
&lt;td>717.5 B&lt;/td>
&lt;td>61.5 B&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>metadata_values&lt;/code>&lt;/td>
&lt;td>389.0 B&lt;/td>
&lt;td>132.6 B&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>output&lt;/code>&lt;/td>
&lt;td>174.7 B&lt;/td>
&lt;td>40.3 B&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>tool_definitions&lt;/code>&lt;/td>
&lt;td>28.0 B&lt;/td>
&lt;td>28.1 B&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>blob_storage_file_path&lt;/code>&lt;/td>
&lt;td>12.6 B&lt;/td>
&lt;td>12.5 B&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>span_id&lt;/code>&lt;/td>
&lt;td>8.1 B&lt;/td>
&lt;td>8.0 B&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>The other 67 columns together&lt;/td>
&lt;td>17.3 B&lt;/td>
&lt;td>13.3 B&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>Three things jump out.&lt;/p>
&lt;p>The first is that &lt;strong>the skeleton is cheap&lt;/strong>. Sixty-seven columns, most of them empty in a normal observation, cost seventeen bytes between them all. ClickHouse compresses a column of empty strings down to almost nothing. Anyone who feared that the thirteen experiment columns or the telemetry ones would weigh can stop fearing it.&lt;/p>
&lt;p>The second is that in the full table &lt;strong>the input is half of everything&lt;/strong>. 717.5 of 1,364.6 compressed bytes. It is the direct consequence of each generation reserialising the history.&lt;/p>
&lt;p>The third is the one I was not expecting. In the listings table, &lt;strong>metadata costs more than input and output together&lt;/strong>: 132.6 against 101.8. The reason lies in how the materialized view truncates. It trims input and output to two hundred characters each, but it trims the metadata &lt;strong>element by element&lt;/strong>: an array with forty values can survive with eight thousand characters. The table that exists in order to be light fills up with the one thing its truncation does not control.&lt;/p>
&lt;h3 id="the-split-by-observation-type">The split by observation type&lt;/h3>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Type&lt;/th>
&lt;th>Observations&lt;/th>
&lt;th>Mean input&lt;/th>
&lt;th>Input p95&lt;/th>
&lt;th>Mean output&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>GENERATION&lt;/td>
&lt;td>21,952&lt;/td>
&lt;td>15,655 B&lt;/td>
&lt;td>55,205 B&lt;/td>
&lt;td>1,077 B&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>AGENT&lt;/td>
&lt;td>22,563&lt;/td>
&lt;td>2,585 B&lt;/td>
&lt;td>6,013 B&lt;/td>
&lt;td>1,300 B&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>TOOL&lt;/td>
&lt;td>21,952&lt;/td>
&lt;td>319 B&lt;/td>
&lt;td>319 B&lt;/td>
&lt;td>2,008 B&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>CHAIN&lt;/td>
&lt;td>67,689&lt;/td>
&lt;td>413 B&lt;/td>
&lt;td>413 B&lt;/td>
&lt;td>20 B&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>SPAN&lt;/td>
&lt;td>65,856&lt;/td>
&lt;td>272 B&lt;/td>
&lt;td>272 B&lt;/td>
&lt;td>206 B&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>Generations are 11 % of the observations and contribute 65 % of the input and output bytes. The 95th percentile of their input is 55 kilobytes, in a benchmark where no conversation goes beyond fifteen tool calls.&lt;/p>
&lt;p>This has a sizing consequence worth saying out loud: &lt;strong>the size of the database is not determined by the number of requests, it is determined by the depth of the turns&lt;/strong>. Doubling the users doubles the disk. Doubling the tool calls per turn multiplies it by rather more than two.&lt;/p>
&lt;h2 id="the-indexes-which-are-almost-half">The indexes, which are almost half&lt;/h2>
&lt;p>Here is the finding that changes a sizing exercise the most.&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>&lt;/th>
&lt;th>Data&lt;/th>
&lt;th>Indexes&lt;/th>
&lt;th>% of disk in indexes&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>events_full&lt;/td>
&lt;td>260.29 MiB&lt;/td>
&lt;td>187.31 MiB&lt;/td>
&lt;td>&lt;strong>41.8 %&lt;/strong>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>events_core&lt;/td>
&lt;td>60.34 MiB&lt;/td>
&lt;td>22.49 MiB&lt;/td>
&lt;td>&lt;strong>27.1 %&lt;/strong>&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>The marks and the primary key are irrelevant: thirty-two kilobytes and three hundred bytes respectively, with the granule size of 64 mebibytes the full table has declared.&lt;/p>
&lt;p>Broken down, and ordered by what they cost:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Index&lt;/th>
&lt;th>Table&lt;/th>
&lt;th>Bytes per observation&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>&lt;code>idx_fts_input_low&lt;/code>&lt;/td>
&lt;td>events_full&lt;/td>
&lt;td>523.1&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>idx_fts_metadata_values&lt;/code>&lt;/td>
&lt;td>events_full&lt;/td>
&lt;td>319.4&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>idx_fts_output_low&lt;/code>&lt;/td>
&lt;td>events_full&lt;/td>
&lt;td>133.5&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>idx_fts_metadata_values&lt;/code>&lt;/td>
&lt;td>events_core&lt;/td>
&lt;td>111.9&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>idx_fts_metadata_names&lt;/code>&lt;/td>
&lt;td>both&lt;/td>
&lt;td>4.3&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>idx_span_id&lt;/code>&lt;/td>
&lt;td>both&lt;/td>
&lt;td>1.2&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>The remaining Bloom filters&lt;/td>
&lt;td>both&lt;/td>
&lt;td>0.1 each&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>The full-text index over the input costs 523 bytes per observation when the column it indexes costs 717. That is, &lt;strong>indexing the input costs almost three quarters of what storing it costs&lt;/strong>. And the metadata one in the listings table costs 111.9 when its column costs 132.6: eighty-five per cent.&lt;/p>
&lt;p>The Bloom filters, on the other hand, are free as far as sizing goes. The four identifier ones and those for model name, experiment and evaluator add up to less than two bytes per observation between them all.&lt;/p>
&lt;p>This is not a criticism of the design. Those indexes are what make text search in the interface respond, and without them the experience would be another thing. It is sizing information that is published nowhere: &lt;strong>when asking for disk for ClickHouse you have to multiply what the columns say by 1.7.&lt;/strong>&lt;/p>
&lt;h2 id="the-materialized-view-as-an-amplifier">The materialized view as an amplifier&lt;/h2>
&lt;p>The second table exists so that listings do not have to touch the large fields. It truncates input and output to two hundred characters and does without the text indexes over them.&lt;/p>
&lt;p>The trimming works: the benchmark&amp;rsquo;s 540.55 MiB of input and output come down to 64.68 MiB, &lt;strong>eight and a half times less&lt;/strong>.&lt;/p>
&lt;p>And even so the second table adds 18.5 % of disk on top of the first. For the reasons already seen: the skeleton survives, the metadata truncated element by element survives, and its own text index over that metadata costs another 111.9 bytes.&lt;/p>
&lt;p>It is worth keeping in mind because it is counter-intuitive. You would think a table storing two hundred characters of text is an appendix. It is four hundred and thirty-five bytes per observation, almost a fifth of the total, and it is paid always, because the materialized view cannot be switched off without leaving the interface with no listings.&lt;/p>
&lt;h2 id="what-happens-on-deletion">What happens on deletion&lt;/h2>
&lt;p>The retention cron runs a lightweight delete. I reproduced it: I deleted the observations older than a date, which removed 67,395 rows of the 200,012.&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Moment&lt;/th>
&lt;th>Visible rows&lt;/th>
&lt;th>Rows in the parts&lt;/th>
&lt;th>Disk&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Before&lt;/td>
&lt;td>200,012&lt;/td>
&lt;td>200,012&lt;/td>
&lt;td>447.68 MiB&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>After the delete&lt;/td>
&lt;td>132,617&lt;/td>
&lt;td>200,012&lt;/td>
&lt;td>447.68 MiB&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>After applying the mask&lt;/td>
&lt;td>132,617&lt;/td>
&lt;td>132,617&lt;/td>
&lt;td>299.11 MiB&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>The delete frees &lt;strong>not one byte&lt;/strong>. The rows stop being visible and the space stays occupied. The disk only goes down at the second step, applying the deleted mask, which frees 33.2 %.&lt;/p>
&lt;p>And that second step is run by a different cron that &lt;strong>ships disabled&lt;/strong>, just like the retention one. With the default values, whoever switches retention on in the interface will see the traces disappear from the view and will never see the disk go down, until an ordinary merge drags in the affected parts, which can take however long it takes.&lt;/p>
&lt;p>There is a third detail. The cleaner works &lt;strong>table by table&lt;/strong>, with an independent deleter for each one. In my test I deleted in the full table and the listings one kept its 200,012 rows, because the materialized view does not propagate deletes. In a real installation both deleters run, but they are two separate operations that can diverge if one fails.&lt;/p>
&lt;p>On top of this you have to add what the fourth article already covered: &lt;strong>neither of the two tables carries an expiry clause&lt;/strong>. There is no automatic expiry in the engine. The whole lifecycle depends on those crons. And per-project retention requires an enterprise edition entitlement, so in the open edition the only retention possible is the one you write yourself.&lt;/p>
&lt;h2 id="projection">Projection&lt;/h2>
&lt;p>With the measured 2,782 bytes per observation, one replica and no retention:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Observations per day&lt;/th>
&lt;th>Agent turns per day&lt;/th>
&lt;th>Per day&lt;/th>
&lt;th>Per month&lt;/th>
&lt;th>Per year&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>100,000&lt;/td>
&lt;td>~11,300&lt;/td>
&lt;td>0.26 GiB&lt;/td>
&lt;td>7.8 GiB&lt;/td>
&lt;td>94.6 GiB&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>1,000,000&lt;/td>
&lt;td>~112,800&lt;/td>
&lt;td>2.59 GiB&lt;/td>
&lt;td>77.7 GiB&lt;/td>
&lt;td>945.6 GiB&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>10,000,000&lt;/td>
&lt;td>~1,128,000&lt;/td>
&lt;td>25.91 GiB&lt;/td>
&lt;td>777.2 GiB&lt;/td>
&lt;td>9,456 GiB&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>The conversion to turns uses the benchmark&amp;rsquo;s 8.86 observations per trace, which depend on the depth of the agents. With deeper agents, the same turn figure produces more observations.&lt;/p>
&lt;p>And there is still a multiplication left. Langfuse recommends &lt;strong>three minimum replicas&lt;/strong> of ClickHouse in Kubernetes. With one million observations a day and three replicas, a year is &lt;strong>2.8 tebibytes of raw disk&lt;/strong>. The official sizing of a hundred gibibytes per replica covers about thirty-eight days at that rate.&lt;/p>
&lt;p>That is the number that is published nowhere and that has to go into the spreadsheet before committing to a service.&lt;/p>
&lt;h2 id="what-this-benchmark-does-not-measure">What this benchmark does not measure&lt;/h2>
&lt;p>For the sake of honesty, and because anyone repeating this is going to get different numbers:&lt;/p>
&lt;p>The data is synthetic. The shape of the row and the topology of the traces are taken from the code and from the measurements in the previous articles, but the text is generated by a sampler, not by a model. I have calibrated its entropy so that compression resembles that of natural language, and that calibration is the largest source of error in the whole article. A load with a lot of structured JSON will compress better; one with a lot of code or a lot of base64, worse.&lt;/p>
&lt;p>It is a single instance, with no replication. In a replicated installation there is a different engine and there are copies.&lt;/p>
&lt;p>It is ClickHouse 26.9.1, above the 25.12 minimum. The text index implementations have changed between versions and I have not compared.&lt;/p>
&lt;p>It does not measure performance: not ingestion per second, not query latency, not the CPU cost of merges. Disk only. The merge cost of full-text indexes is a topic with a life of its own and is left for the saturation runbook.&lt;/p>
&lt;p>And it does not measure object storage, which in version 4 is mandatory and keeps a copy of the original event. That is additional disk this benchmark does not touch.&lt;/p>
&lt;h2 id="checklist">Checklist&lt;/h2>
&lt;ul>
&lt;li>Do not size using the official container sizing: it covers CPU and memory, not disk growth.&lt;/li>
&lt;li>Count &lt;strong>2,782 bytes per observation&lt;/strong> as a starting point, and adjust by measuring your own load.&lt;/li>
&lt;li>Multiply by the number of replicas before asking for the volume.&lt;/li>
&lt;li>Multiply what the columns suggest by 1.7, because the indexes are 41.8 % of the full table&amp;rsquo;s disk.&lt;/li>
&lt;li>Measure your own installation with the system tables instead of trusting this article: active parts for the total, columns for the breakdown, skip indexes for what is not data.&lt;/li>
&lt;li>Watch the metadata, which is the only thing the materialized view&amp;rsquo;s truncation does not control, because it trims each element and not the whole.&lt;/li>
&lt;li>Size by turn depth, not by number of requests.&lt;/li>
&lt;li>Switch on both crons, the retention one and the deleted mask one, and check that the disk really goes down after the second.&lt;/li>
&lt;li>Reckon on per-project retention requiring a licence, and budget the work of writing it if you do not have one.&lt;/li>
&lt;li>Remember that the tables have no expiry in the engine: with no cron, they grow for ever.&lt;/li>
&lt;/ul>
&lt;h2 id="traps-and-things-that-are-not-what-they-look-like">Traps and things that are not what they look like&lt;/h2>
&lt;ul>
&lt;li>&lt;strong>Langfuse publishes no capacity figure at all&lt;/strong>, neither in its code nor in its documentation.&lt;/li>
&lt;li>&lt;strong>A compression ratio without saying what was compressed means nothing&lt;/strong>: the same schema gives 38 times with repeated text and 3.68 with realistic text.&lt;/li>
&lt;li>&lt;strong>41.8 % of the full table&amp;rsquo;s disk is indexes&lt;/strong>, not data.&lt;/li>
&lt;li>&lt;strong>Indexing the input costs almost as much as storing it&lt;/strong>: 523 bytes per observation against 717.&lt;/li>
&lt;li>&lt;strong>The 78-column skeleton is irrelevant&lt;/strong>: seventeen bytes per observation across the sixty-seven small columns.&lt;/li>
&lt;li>&lt;strong>In the listings table metadata costs more than input and output together.&lt;/strong>&lt;/li>
&lt;li>&lt;strong>The materialized view&amp;rsquo;s truncation trims each element of the metadata, not the whole array.&lt;/strong>&lt;/li>
&lt;li>&lt;strong>The listings table is not an appendix&lt;/strong>: it adds 18.5 % of the first table&amp;rsquo;s disk despite storing two hundred characters.&lt;/li>
&lt;li>&lt;strong>A delete frees no disk.&lt;/strong> The mask has to be applied, and that cron ships disabled.&lt;/li>
&lt;li>&lt;strong>The retention one also ships disabled&lt;/strong> out of the box.&lt;/li>
&lt;li>&lt;strong>The cleaner works table by table&lt;/strong>; the materialized view does not propagate deletes.&lt;/li>
&lt;li>&lt;strong>The event tables carry no expiry clause.&lt;/strong>&lt;/li>
&lt;li>&lt;strong>Per-project retention is a paid feature&lt;/strong> when self-hosted.&lt;/li>
&lt;li>&lt;strong>Generations are 11 % of the observations and 65 % of the bytes.&lt;/strong>&lt;/li>
&lt;li>&lt;strong>Bloom filters are free&lt;/strong> as far as disk goes; the full-text ones are not.&lt;/li>
&lt;/ul>
&lt;h2 id="closing">Closing&lt;/h2>
&lt;p>What I take from this benchmark is not the figure, although the figure was needed. It is that the split is not where you look for it.&lt;/p>
&lt;p>When somebody sizes a trace database, they look at the size of the prompts. It is the natural thing to do: they are the large fields and they are the ones you see in the interface. And it turns out that in this architecture the prompts are half the compressed data, and the compressed data is a little over half the disk. The rest is search structures nobody counts because they do not show up when you look at a row.&lt;/p>
&lt;p>There is a pattern there that goes beyond this tool. An observability database is, by definition, a database queried in unforeseen ways: by free text, by user, by session, by whatever is needed when something goes wrong at three in the morning. That ability to search by anything is paid for in disk, always, and it is paid at write time and not at search time. Langfuse&amp;rsquo;s design pays it fairly explicitly, with four full-text indexes declared in the migration, in plain sight for anyone who wants to read them.&lt;/p>
&lt;p>What was missing is for somebody to put the number alongside. Now it is there, with its margin of error and its method on the table, so that the next person to measure it can tell me where I got it wrong.&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>&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>Real ClickHouse capacity and cost (this article): 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/">Self-hosted Langfuse: architecture and tuning&lt;/a> — the architecture of the previous version, where this setup comes from.&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> — who produces the traces measured here.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/sizing-agents-decode-bottleneck/">Sizing for agents&lt;/a> — the other side of sizing, that of the gateway and the inference engine.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/deepagents-free-sdk-licensed-server/">deepagents on a cluster of your own&lt;/a> — the harness that generates these observations and where it keeps its own state.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/technical-controls-ens-iso-42001-eu-ai-act-cross-mapping/">Technical controls for ENS, 42001 and the AI Act&lt;/a> — the framework where retention and deletion fit.&lt;/li>
&lt;/ul>
&lt;h2 id="sources">Sources&lt;/h2>
&lt;ul>
&lt;li>Langfuse, migraciones de ClickHouse (&lt;code>packages/shared/clickhouse/migrations/canonical/&lt;/code>, ficheros 0039 a 0047): &lt;a href="https://github.com/langfuse/langfuse">https://github.com/langfuse/langfuse&lt;/a>.&lt;/li>
&lt;li>Langfuse, escritura del worker en ClickHouse (&lt;code>worker/src/services/IngestionService/&lt;/code>, &lt;code>worker/src/services/ClickhouseWriter/&lt;/code>): &lt;a href="https://github.com/langfuse/langfuse">https://github.com/langfuse/langfuse&lt;/a>.&lt;/li>
&lt;li>Langfuse, limpiador de retención por lotes y limpiador de máscaras de borrado (&lt;code>worker/src/features/batch-data-retention-cleaner/&lt;/code>, &lt;code>worker/src/features/deleted-mask-cleaner/&lt;/code>): &lt;a href="https://github.com/langfuse/langfuse">https://github.com/langfuse/langfuse&lt;/a>.&lt;/li>
&lt;li>Langfuse, guía de autoalojamiento y requisitos de infraestructura: &lt;a href="https://langfuse.com/self-hosting">https://langfuse.com/self-hosting&lt;/a>.&lt;/li>
&lt;li>ClickHouse, tablas del sistema &lt;code>parts&lt;/code>, &lt;code>columns&lt;/code> y &lt;code>data_skipping_indices&lt;/code>: &lt;a href="https://clickhouse.com/docs/en/operations/system-tables">https://clickhouse.com/docs/en/operations/system-tables&lt;/a>.&lt;/li>
&lt;li>ClickHouse, índices de salto y de texto completo: &lt;a href="https://clickhouse.com/docs/en/engines/table-engines/mergetree-family/mergetree">https://clickhouse.com/docs/en/engines/table-engines/mergetree-family/mergetree&lt;/a>.&lt;/li>
&lt;li>ClickHouse, borrados ligeros y máscara de borrado: &lt;a href="https://clickhouse.com/docs/en/guides/developer/lightweight-delete">https://clickhouse.com/docs/en/guides/developer/lightweight-delete&lt;/a>.&lt;/li>
&lt;/ul></description></item><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 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>FP8 end to end: enable it, measure quality and decide with data</title><link>https://blog.lo0.es/en/posts/fp8-end-to-end-enable-measure-quality-decide/</link><pubDate>Fri, 05 Jun 2026 04:00:00 +0000</pubDate><guid>https://blog.lo0.es/en/posts/fp8-end-to-end-enable-measure-quality-decide/</guid><description>&lt;h2 id="tldr">TL;DR&lt;/h2>
&lt;p>FP8 is the configuration change with the highest impact per unit of effort available on H100 and Ada Lovelace hardware. On an H100 it enables native FP8 tensor cores: +40-60% decode throughput and ×2 the VRAM available for the KV cache. On an RTX 4090 and an L40 the compute benefit is smaller, but the ×2 VRAM is real and translates directly into twice the concurrency. The risk is quality degradation, which on well-calibrated modern models is &amp;lt;0.5% on standard benchmarks but can be larger on formal reasoning. The correct workflow is not to switch it on and pray: it is to switch it on in staging, run the eval suite, correlate quality with throughput in OTel, and decide with data.&lt;/p>
&lt;hr>
&lt;h2 id="the-analogy">The analogy&lt;/h2>
&lt;p>A photographer who works with 35 mm negatives and moves to digital. Digital photos take up less space and are processed faster. But a low-resolution photo of a landscape can be indistinguishable from the high-resolution one to the human eye, while a low-resolution photo of text loses letters. Exactly the same trade-off applies to FP8: for tasks where numerical imprecision is averaged out over thousands of activations (conversation, summarisation, RAG), it is practically invisible. For tasks where a single wrong multiplication propagates into an incorrect answer (formal mathematics, critical code), it can be decisive.&lt;/p>
&lt;hr>
&lt;h2 id="the-three-layers-of-fp8-in-vllm">The three layers of FP8 in vLLM&lt;/h2>
&lt;p>FP8 is not a single flag: it is three independent layers that are enabled separately and have different benefits.&lt;/p>
&lt;p>&lt;strong>Layer 1 — Model weights (&lt;code>--quantization fp8&lt;/code>):&lt;/strong>
The model weights are stored and computed in FP8 E4M3. The models have to be pre-quantised (available on HuggingFace with a &lt;code>-FP8&lt;/code> or &lt;code>-fp8&lt;/code> suffix) or quantised at load time with calibration. The benefit: the model takes up half the VRAM and the weight matmuls are 2× faster on an H100.&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"># Pre-quantised model (recommended for production)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">vllm serve neuralmagic/Meta-Llama-3.1-70B-Instruct-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> --quantization fp8
&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"># Or on-the-fly quantisation (no extra files, somewhat slower on the first tokens)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">vllm serve meta-llama/Meta-Llama-3.1-70B-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> --quantization 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> --kv-cache-dtype auto
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;strong>Layer 2 — KV cache (&lt;code>--kv-cache-dtype fp8&lt;/code>):&lt;/strong>
The K and V tensors of the KV cache are stored in FP8 instead of BF16. It halves the size of the KV cache, doubling the number of tokens that fit in VRAM. It does not affect the model weights.&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">vllm serve my-model &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> --calculate-kv-scales &lt;span class="c1"># dynamic calibration, mandatory to minimise degradation&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;strong>Layer 3 — Activations (automatic on an H100):&lt;/strong>
On Hopper GPUs, vLLM automatically enables FP8 for the intermediate activations when both previous layers are active. No extra flag is required.&lt;/p>
&lt;p>&lt;strong>Full production configuration:&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">vllm serve neuralmagic/Meta-Llama-3.1-70B-Instruct-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> --quantization 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> --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> --calculate-kv-scales &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> --gpu-memory-utilization 0.92 &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> --max-model-len &lt;span class="m">16384&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;hr>
&lt;h2 id="the-measurable-impact-by-hardware">The measurable impact by hardware&lt;/h2>
&lt;h3 id="h100-sxm-hopper-native-fp8-tensor-cores">H100 SXM (Hopper, native FP8 tensor cores)&lt;/h3>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th style="text-align:left">Metric&lt;/th>
&lt;th style="text-align:right">BF16 baseline&lt;/th>
&lt;th style="text-align:right">FP8 enabled&lt;/th>
&lt;th style="text-align:right">Delta&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td style="text-align:left">Decode throughput (tok/s, 70B, batch 32)&lt;/td>
&lt;td style="text-align:right">~1,800&lt;/td>
&lt;td style="text-align:right">~2,700&lt;/td>
&lt;td style="text-align:right">+50%&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td style="text-align:left">Model VRAM (70B)&lt;/td>
&lt;td style="text-align:right">140 GB&lt;/td>
&lt;td style="text-align:right">70 GB&lt;/td>
&lt;td style="text-align:right">−50%&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td style="text-align:left">KV cache VRAM available (on 4×H100)&lt;/td>
&lt;td style="text-align:right">180 GB&lt;/td>
&lt;td style="text-align:right">250 GB&lt;/td>
&lt;td style="text-align:right">+39%&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td style="text-align:left">Maximum concurrency (ctx 8K)&lt;/td>
&lt;td style="text-align:right">~22,500 tok&lt;/td>
&lt;td style="text-align:right">~31,250 tok&lt;/td>
&lt;td style="text-align:right">+39%&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>This is equivalent to an extra replica for free in terms of KV cache capacity.&lt;/p>
&lt;h3 id="rtx-4090-ada-lovelace-fp8-in-cuda-but-without-dedicated-tensor-cores">RTX 4090 (Ada Lovelace, FP8 in CUDA but without dedicated tensor cores)&lt;/h3>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th style="text-align:left">Metric&lt;/th>
&lt;th style="text-align:right">BF16/Q4 baseline&lt;/th>
&lt;th style="text-align:right">FP8 KV cache added&lt;/th>
&lt;th style="text-align:right">Delta&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td style="text-align:left">Decode throughput (tok/s, 14B Q4)&lt;/td>
&lt;td style="text-align:right">~45&lt;/td>
&lt;td style="text-align:right">~47&lt;/td>
&lt;td style="text-align:right">+4%&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td style="text-align:left">KV cache VRAM available&lt;/td>
&lt;td style="text-align:right">15 GB&lt;/td>
&lt;td style="text-align:right">15 GB (same model)&lt;/td>
&lt;td style="text-align:right">—&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td style="text-align:left">Total cache tokens (ctx 8K)&lt;/td>
&lt;td style="text-align:right">~46,000&lt;/td>
&lt;td style="text-align:right">~92,000&lt;/td>
&lt;td style="text-align:right">+100%&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td style="text-align:left">Maximum concurrency (ctx 8K)&lt;/td>
&lt;td style="text-align:right">~5 users&lt;/td>
&lt;td style="text-align:right">~11 users&lt;/td>
&lt;td style="text-align:right">+120%&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>On Ada, the compute benefit is smaller (the FP8 tensor cores do not have the same width as on Hopper), but the ×2 in KV cache capacity is entirely real and translates into twice the possible concurrent users.&lt;/p>
&lt;hr>
&lt;h2 id="the-correct-workflow-enable-measure-decide">The correct workflow: enable, measure, decide&lt;/h2>
&lt;p>Enabling FP8 directly in production without validating quality is inadequate. The correct workflow has four steps.&lt;/p>
&lt;h3 id="step-1-baseline-in-staging">Step 1: baseline in staging&lt;/h3>
&lt;p>Before enabling FP8, record the quality metrics of the current BF16 model. The most reproducible way is to run an eval suite over a fixed dataset and save the results:&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"># Install lm-evaluation-harness&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">pip install lm-eval
&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"># BF16 baseline&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">lm_eval --model vllm &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> --model_args &lt;span class="nv">pretrained&lt;/span>&lt;span class="o">=&lt;/span>meta-llama/Meta-Llama-3.1-70B-Instruct,dtype&lt;span class="o">=&lt;/span>bfloat16 &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> --tasks mmlu,hellaswag,gsm8k &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_fewshot &lt;span class="m">5&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> --output_path ./results/baseline_bf16.json
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h3 id="step-2-enable-fp8-and-run-the-same-eval-suite">Step 2: enable FP8 and run the same eval suite&lt;/h3>
&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"># FP8&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">lm_eval --model vllm &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> --model_args &lt;span class="nv">pretrained&lt;/span>&lt;span class="o">=&lt;/span>neuralmagic/Meta-Llama-3.1-70B-Instruct-FP8,quantization&lt;span class="o">=&lt;/span>fp8,kv_cache_dtype&lt;span class="o">=&lt;/span>fp8,calculate_kv_scales&lt;span class="o">=&lt;/span>&lt;span class="nb">true&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> --tasks mmlu,hellaswag,gsm8k &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_fewshot &lt;span class="m">5&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> --output_path ./results/fp8_full.json
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h3 id="step-3-compute-the-degradation">Step 3: compute the degradation&lt;/h3>
&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"># compare_eval.py&lt;/span>
&lt;/span>&lt;/span>&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>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="k">with&lt;/span> &lt;span class="nb">open&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;results/baseline_bf16.json&amp;#34;&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="k">as&lt;/span> &lt;span class="n">f&lt;/span>&lt;span class="p">:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">baseline&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">json&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">load&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">f&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="nb">open&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;results/fp8_full.json&amp;#34;&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="k">as&lt;/span> &lt;span class="n">f&lt;/span>&lt;span class="p">:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">fp8&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">json&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">load&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">f&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">tasks&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;mmlu&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s2">&amp;#34;hellaswag&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s2">&amp;#34;gsm8k&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="nb">print&lt;/span>&lt;span class="p">(&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="s1">&amp;#39;Task&amp;#39;&lt;/span>&lt;span class="si">:&lt;/span>&lt;span class="s2">&amp;lt;15&lt;/span>&lt;span class="si">}&lt;/span>&lt;span class="s2"> &lt;/span>&lt;span class="si">{&lt;/span>&lt;span class="s1">&amp;#39;BF16&amp;#39;&lt;/span>&lt;span class="si">:&lt;/span>&lt;span class="s2">&amp;gt;8&lt;/span>&lt;span class="si">}&lt;/span>&lt;span class="s2"> &lt;/span>&lt;span class="si">{&lt;/span>&lt;span class="s1">&amp;#39;FP8&amp;#39;&lt;/span>&lt;span class="si">:&lt;/span>&lt;span class="s2">&amp;gt;8&lt;/span>&lt;span class="si">}&lt;/span>&lt;span class="s2"> &lt;/span>&lt;span class="si">{&lt;/span>&lt;span class="s1">&amp;#39;Delta&amp;#39;&lt;/span>&lt;span class="si">:&lt;/span>&lt;span class="s2">&amp;gt;8&lt;/span>&lt;span class="si">}&lt;/span>&lt;span class="s2"> &lt;/span>&lt;span class="si">{&lt;/span>&lt;span class="s1">&amp;#39;OK?&amp;#39;&lt;/span>&lt;span class="si">:&lt;/span>&lt;span class="s2">&amp;gt;6&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="nb">print&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;-&amp;#34;&lt;/span> &lt;span class="o">*&lt;/span> &lt;span class="mi">50&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">task&lt;/span> &lt;span class="ow">in&lt;/span> &lt;span class="n">tasks&lt;/span>&lt;span class="p">:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">b&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">baseline&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;results&amp;#34;&lt;/span>&lt;span class="p">][&lt;/span>&lt;span class="n">task&lt;/span>&lt;span class="p">][&lt;/span>&lt;span class="s2">&amp;#34;acc,none&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">f&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">fp8&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;results&amp;#34;&lt;/span>&lt;span class="p">][&lt;/span>&lt;span class="n">task&lt;/span>&lt;span class="p">][&lt;/span>&lt;span class="s2">&amp;#34;acc,none&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">delta&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="n">f&lt;/span> &lt;span class="o">-&lt;/span> &lt;span class="n">b&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="o">/&lt;/span> &lt;span class="n">b&lt;/span> &lt;span class="o">*&lt;/span> &lt;span class="mi">100&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">ok&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s2">&amp;#34;✓&amp;#34;&lt;/span> &lt;span class="k">if&lt;/span> &lt;span class="nb">abs&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">delta&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="o">&amp;lt;&lt;/span> &lt;span class="mf">1.0&lt;/span> &lt;span class="k">else&lt;/span> &lt;span class="s2">&amp;#34;✗ REVIEW&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nb">print&lt;/span>&lt;span class="p">(&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">task&lt;/span>&lt;span class="si">:&lt;/span>&lt;span class="s2">&amp;lt;15&lt;/span>&lt;span class="si">}&lt;/span>&lt;span class="s2"> &lt;/span>&lt;span class="si">{&lt;/span>&lt;span class="n">b&lt;/span>&lt;span class="si">:&lt;/span>&lt;span class="s2">&amp;gt;8.3f&lt;/span>&lt;span class="si">}&lt;/span>&lt;span class="s2"> &lt;/span>&lt;span class="si">{&lt;/span>&lt;span class="n">f&lt;/span>&lt;span class="si">:&lt;/span>&lt;span class="s2">&amp;gt;8.3f&lt;/span>&lt;span class="si">}&lt;/span>&lt;span class="s2"> &lt;/span>&lt;span class="si">{&lt;/span>&lt;span class="n">delta&lt;/span>&lt;span class="si">:&lt;/span>&lt;span class="s2">&amp;gt;+7.1f&lt;/span>&lt;span class="si">}&lt;/span>&lt;span class="s2">% &lt;/span>&lt;span class="si">{&lt;/span>&lt;span class="n">ok&lt;/span>&lt;span class="si">:&lt;/span>&lt;span class="s2">&amp;gt;6&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;/code>&lt;/pre>&lt;/div>&lt;p>Decision thresholds documented in MLPerf Inference 2025:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>&amp;lt; 0.5% degradation&lt;/strong>: enable in production without restrictions.&lt;/li>
&lt;li>&lt;strong>0.5% – 1.5%&lt;/strong>: enable with active quality monitoring via LLM-as-judge.&lt;/li>
&lt;li>&lt;strong>&amp;gt; 1.5%&lt;/strong>: investigate before enabling — a possible calibration problem or an incompatible model.&lt;/li>
&lt;/ul>
&lt;h3 id="step-4-domain-eval-with-llm-as-judge">Step 4: domain eval with LLM-as-judge&lt;/h3>
&lt;p>Academic benchmarks measure what they measure. Your use case may be different. Adding 200 representative samples from your domain, evaluated by an LLM judge, closes the gap:&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"># domain_eval.py&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&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">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">client&lt;/span> &lt;span class="o">=&lt;/span> &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">judge&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">OpenAI&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">base_url&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;http://judge-llm:8000/v1&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">api_key&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;token&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="c1"># Load the 200 curated production samples (prompt + expected answer)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">samples&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">load_domain_samples&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;eval_dataset_200.json&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">scores_bf16&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">scores_fp8&lt;/span> &lt;span class="o">=&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">for&lt;/span> &lt;span class="n">sample&lt;/span> &lt;span class="ow">in&lt;/span> &lt;span class="n">samples&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">model_type&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">endpoint&lt;/span> &lt;span class="ow">in&lt;/span> &lt;span class="p">[(&lt;/span>&lt;span class="s2">&amp;#34;bf16&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s2">&amp;#34;http://staging-bf16:8000&amp;#34;&lt;/span>&lt;span class="p">),&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;fp8&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s2">&amp;#34;http://staging-fp8:8000&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">response&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">call_model&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">endpoint&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">sample&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;prompt&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">score&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">judge&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;Qwen/Qwen2.5-72B-Instruct&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>&lt;/span>&lt;span class="line">&lt;span class="cl"> &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>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="s2">&amp;#34;content&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="sa">f&lt;/span>&lt;span class="s2">&amp;#34;Rate this answer from 1 to 5 on accuracy and completeness.&lt;/span>&lt;span class="se">\n&lt;/span>&lt;span class="s2">Question: &lt;/span>&lt;span class="si">{&lt;/span>&lt;span class="n">sample&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s1">&amp;#39;prompt&amp;#39;&lt;/span>&lt;span class="p">]&lt;/span>&lt;span class="si">}&lt;/span>&lt;span class="se">\n&lt;/span>&lt;span class="s2">Expected answer: &lt;/span>&lt;span class="si">{&lt;/span>&lt;span class="n">sample&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s1">&amp;#39;expected&amp;#39;&lt;/span>&lt;span class="p">]&lt;/span>&lt;span class="si">}&lt;/span>&lt;span class="se">\n&lt;/span>&lt;span class="s2">Model answer: &lt;/span>&lt;span class="si">{&lt;/span>&lt;span class="n">response&lt;/span>&lt;span class="si">}&lt;/span>&lt;span class="se">\n\n&lt;/span>&lt;span class="s2">Reply with a number from 1 to 5 only.&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="p">)&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">choices&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="mi">0&lt;/span>&lt;span class="p">]&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">message&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">content&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">strip&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">if&lt;/span> &lt;span class="n">model_type&lt;/span> &lt;span class="o">==&lt;/span> &lt;span class="s2">&amp;#34;bf16&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">scores_bf16&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">append&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">score&lt;/span>&lt;span class="p">))&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &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="n">scores_fp8&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">append&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">score&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="kn">import&lt;/span> &lt;span class="nn">numpy&lt;/span> &lt;span class="k">as&lt;/span> &lt;span class="nn">np&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="nb">print&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="sa">f&lt;/span>&lt;span class="s2">&amp;#34;Mean BF16 score: &lt;/span>&lt;span class="si">{&lt;/span>&lt;span class="n">np&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">mean&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">scores_bf16&lt;/span>&lt;span class="p">)&lt;/span>&lt;span class="si">:&lt;/span>&lt;span class="s2">.2f&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="nb">print&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="sa">f&lt;/span>&lt;span class="s2">&amp;#34;Mean FP8 score: &lt;/span>&lt;span class="si">{&lt;/span>&lt;span class="n">np&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">mean&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">scores_fp8&lt;/span>&lt;span class="p">)&lt;/span>&lt;span class="si">:&lt;/span>&lt;span class="s2">.2f&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="nb">print&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="sa">f&lt;/span>&lt;span class="s2">&amp;#34;Degradation: &lt;/span>&lt;span class="si">{&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">np&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">mean&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">scores_fp8&lt;/span>&lt;span class="p">)&lt;/span>&lt;span class="o">-&lt;/span>&lt;span class="n">np&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">mean&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">scores_bf16&lt;/span>&lt;span class="p">))&lt;/span>&lt;span class="o">/&lt;/span>&lt;span class="n">np&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">mean&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">scores_bf16&lt;/span>&lt;span class="p">)&lt;/span>&lt;span class="o">*&lt;/span>&lt;span class="mi">100&lt;/span>&lt;span class="si">:&lt;/span>&lt;span class="s2">.1f&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;/code>&lt;/pre>&lt;/div>&lt;hr>
&lt;h2 id="otel--langfuse-correlation-the-dashboard-that-decides">OTel + Langfuse correlation: the dashboard that decides&lt;/h2>
&lt;p>The moment of decision rests on a single dashboard with two signals on the same time axis:&lt;/p>
&lt;p>&lt;strong>Signal 1 — Throughput (Prometheus):&lt;/strong>&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="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">generation_tokens_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>Signal 2 — Mean quality (Langfuse → Prometheus via exporter):&lt;/strong>&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"># If you have configured Langfuse with scores exported via 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="nv">langfuse_score_value&lt;/span>&lt;span class="p">{&lt;/span>&lt;span class="nl">name&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="p">&amp;#34;&lt;/span>&lt;span class="s">llm_judge_domain&lt;/span>&lt;span class="p">&amp;#34;}&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>The expected pattern after enabling FP8: throughput rises by 40-60% and quality stays within ±0.1 points. If quality drops by more than 0.3 points and stays low, there is a real problem.&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: quality drops more than 0.2 points, sustained, after the change&lt;/span>&lt;span class="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">FP8CalidadDegradada&lt;/span>&lt;span class="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">avg_over_time&lt;/span>&lt;span class="o">(&lt;/span>&lt;span class="nv">langfuse_score_value&lt;/span>&lt;span class="p">{&lt;/span>&lt;span class="nl">name&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="p">&amp;#34;&lt;/span>&lt;span class="s">llm_judge_domain&lt;/span>&lt;span class="p">&amp;#34;}[&lt;/span>&lt;span class="s">30m&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="o">&amp;lt;&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">(&lt;/span>&lt;span class="kr">avg_over_time&lt;/span>&lt;span class="o">(&lt;/span>&lt;span class="nv">langfuse_score_value&lt;/span>&lt;span class="p">{&lt;/span>&lt;span class="nl">name&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="p">&amp;#34;&lt;/span>&lt;span class="s">llm_judge_domain&lt;/span>&lt;span class="p">&amp;#34;}[&lt;/span>&lt;span class="s">1d&lt;/span>&lt;span class="p">]&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">offset&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s">2h&lt;/span>&lt;span class="o">)&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">-&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="mf">0.2&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="nv">FOR&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s">15m&lt;/span>&lt;span class="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">Possible quality degradation after the FP8 configuration change&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;hr>
&lt;h2 id="when-not-to-enable-fp8">When NOT to enable FP8&lt;/h2>
&lt;p>FP8 is not always the right answer. The cases where degradation exceeds the acceptable threshold:&lt;/p>
&lt;p>&lt;strong>Formal mathematical reasoning:&lt;/strong> GSM8K and MATH are the benchmarks most sensitive to FP8. If your use case is solving mathematical problems or precise financial calculation, measure specifically on these benchmarks before enabling it.&lt;/p>
&lt;p>&lt;strong>Critical code with tests:&lt;/strong> numerical precision affects the probability of tokens at key positions in a function. The risk is not that the code &amp;ldquo;looks&amp;rdquo; bad, but that it passes superficial tests while having subtle bugs.&lt;/p>
&lt;p>&lt;strong>Very long contexts without &lt;code>--calculate-kv-scales&lt;/code>:&lt;/strong> without dynamic scale calibration, the accumulated numerical error in the KV cache grows with the context. With &lt;code>--calculate-kv-scales&lt;/code> active, the impact is minimal up to 32K tokens.&lt;/p>
&lt;p>&lt;strong>Small models (&amp;lt;7B):&lt;/strong> the FP8 conversion overhead can exceed the throughput benefit. The break-even point is around 7B parameters.&lt;/p>
&lt;hr>
&lt;h2 id="see-also">See also&lt;/h2>
&lt;ul>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/multimodal-on-premise-serving-vlm-vllm/">Multimodal on-premise: serving a VLM with vLLM (vision + language)&lt;/a> — FP8 so that a VLM fits with its heavy budget of visual tokens.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/quantization-llm-inference-fp8-int4-gguf/">the maths of FP8 E4M3: what the 4-bit exponent and the 3-bit mantissa are, and why this specific format was chosen over INT8&lt;/a>&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/kv-cache-working-memory-llm-inference/">the KV cache size formula: why moving to FP8 divides it exactly by two&lt;/a>&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/optimising-decode-vllm-squeezing-every-token-small-hardware/">&lt;code>--kv-cache-dtype fp8&lt;/code> and &lt;code>--calculate-kv-scales&lt;/code> in the context of the full decode tuning&lt;/a>&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/instrumenting-vllm-otel-measuring-optimisations/">how to set up the Langfuse + Prometheus correlation on a single dashboard for the FP8 before/after&lt;/a>&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/evals-llm-layer-after-tracing/">the full eval suite: how to build the 200-sample domain dataset and the LLM judge that verifies quality&lt;/a>&lt;/p>
&lt;/li>
&lt;/ul>
&lt;h3 id="in-this-same-series">In this same series&lt;/h3>
&lt;ul>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/vllm-batch-sizing-two-hour-grid-search/">the max-num-seqs × max-num-batched-tokens grid search: the free optimisation with the biggest impact before touching quantisation&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/prefix-cache-hit-rate-engineering/">prefix cache hit rate engineering: going from 15% to 75% without adding hardware&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/one-big-replica-or-many-small-ones-platform-decision/">TP=4×1 vs TP=2×2: the architectural decision that determines how to scale what FP8 frees up&lt;/a>&lt;/li>
&lt;/ul>
&lt;hr>
&lt;h2 id="references">References&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://vllm.ai/blog/2026-04-22-fp8-kvcache">The State of FP8 KV-Cache and Attention Quantization in vLLM — vLLM Blog (abril 2026)&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://docs.vllm.ai/en/v0.8.5/features/quantization/fp8.html">FP8 W8A8 — vLLM Documentation&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://mlcommons.org/benchmarks/inference-datacenter/">MLPerf Inference v5.1 — resultados de calidad FP8 (sep 2025)&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://ifactoryapp.com/sap-integration/on-prem-ai/fp4-vs-fp8-vs-fp16-llm-inference">FP4 vs FP8 vs FP16 LLM Inference: Quality and Speed Tradeoffs&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://www.spheron.network/blog/vllm-production-deployment-2026/">vLLM Production Deployment 2026: FP8 Docker Setup on H100&lt;/a>&lt;/li>
&lt;/ul></description></item><item><title>Evaluating a RAG system without fooling yourself: RAGAS, the golden dataset and the four metrics that matter</title><link>https://blog.lo0.es/en/posts/evaluating-rag-ragas-golden-dataset-metrics/</link><pubDate>Thu, 04 Jun 2026 09:00:00 +0200</pubDate><guid>https://blog.lo0.es/en/posts/evaluating-rag-ragas-golden-dataset-metrics/</guid><description>&lt;h2 id="tldr">TL;DR&lt;/h2>
&lt;p>A RAG pipeline fails in ways that user satisfaction cannot distinguish: the LLM can hallucinate even with good chunks, or retrieval can ignore key documents even though the LLM synthesises well what it receives. RAGAS breaks evaluation down into four orthogonal metrics, faithfulness, answer relevance, context precision and context recall, each pointing at a different sub-component. The golden dataset is the reference calibrator; without it the metrics have no anchor. The complete stack runs 100 % on-premise with vLLM as the judge and Langfuse for traceability.&lt;/p>
&lt;hr>
&lt;h2 id="the-master-analogy-the-quality-inspector-at-a-furniture-factory">The master analogy: the quality inspector at a furniture factory&lt;/h2>
&lt;p>Imagine you manufacture chairs. You could ask customers &amp;ldquo;is it comfortable?&amp;rdquo; and leave it there. But that question does not tell you what to fix when the answer is &amp;ldquo;no&amp;rdquo;. The quality inspector does not ask that: they measure the board with a Shore hardness test, check that each leg is exactly 45 cm, verify that the assembly manual includes the twelve screws from the BOM, and detect whether a low-density board slipped past the incoming filter.&lt;/p>
&lt;p>RAGAS is that inspector applied to RAG:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Faithfulness&lt;/strong> → does the board have the specified hardness? The LLM can only use the material (chunks) that retrieval hands it.&lt;/li>
&lt;li>&lt;strong>Context Precision&lt;/strong> → is the leg exactly the right length? Of the K retrieved chunks, how many are genuinely useful and how many are filler that confuses the assembler?&lt;/li>
&lt;li>&lt;strong>Context Recall&lt;/strong> → does the manual include all the screws? Of all the facts the correct answer should contain, how many appear in the retrieved chunks?&lt;/li>
&lt;li>&lt;strong>Noise Sensitivity&lt;/strong> → if the operator uses a medium-low density board, does it show in the final product? If you introduce irrelevant chunks, does the LLM start hallucinating?&lt;/li>
&lt;/ul>
&lt;p>Without measuring each dimension separately, the diagnosis is opaque: &amp;ldquo;the RAG is not working well&amp;rdquo; does not tell you whether to repair the embedder, the reranker, the prompt or the corpus.&lt;/p>
&lt;hr>
&lt;h2 id="the-problem-of-evaluating-rag">The problem of evaluating RAG&lt;/h2>
&lt;p>Classification has one uncomfortable virtue: if you predict 87 out of 100 labels correctly, accuracy = 0.87. There is no ambiguity. RAG does not enjoy that luxury.&lt;/p>
&lt;p>A RAG system can fail in at least three independent ways:&lt;/p>
&lt;ol>
&lt;li>&lt;strong>Retrieval correct, LLM hallucinates&lt;/strong>: the chunks contain the correct answer, but the LLM generates claims that are not in those chunks. Faithfulness low; context recall high.&lt;/li>
&lt;li>&lt;strong>LLM correct, retrieval fails&lt;/strong>: retrieval returns irrelevant chunks (low context precision) or incomplete ones (low context recall). If the LLM has enough parametric knowledge, it may look as though it is answering well, but in reality it is ignoring the context, which is a time bomb once the parametric knowledge goes stale.&lt;/li>
&lt;li>&lt;strong>Retrieval and LLM both correct, answer does not answer the question&lt;/strong>: the answer is faithful to the context and the chunks are relevant, but the question was a different one. Answer relevance low.&lt;/li>
&lt;/ol>
&lt;p>Each failure mode requires a different metric and a different corrective action. Using a single metric (BLEU, ROUGE, user satisfaction) mixes the signals and makes it impossible to prioritise improvement work.&lt;/p>
&lt;hr>
&lt;h2 id="the-four-ragas-metrics">The four RAGAS metrics&lt;/h2>
&lt;h3 id="1-faithfulness--fidelity-to-the-context">1. Faithfulness — fidelity to the context&lt;/h3>
&lt;p>&lt;strong>Question:&lt;/strong> how many claims in the generated answer are supported by the retrieved chunks?&lt;/p>
&lt;p>&lt;strong>Calculation:&lt;/strong>&lt;/p>
$$\text{Faithfulness} = \frac{|\text{claims supported by the context}|}{|\text{total claims in the answer}|}$$
&lt;p>The process uses an LLM-as-judge (see https://blog.lo0.es/en/posts/llm-as-judge-exam-marker-not-oracle/): first the atomic claims are extracted from the answer (&amp;ldquo;the model was released in 2023&amp;rdquo;, &amp;ldquo;it supports contexts of 128k tokens&amp;rdquo;, and so on), then the judge classifies each claim as &lt;em>supported&lt;/em> or &lt;em>not supported&lt;/em> by the chunks.&lt;/p>
&lt;p>&lt;strong>Example:&lt;/strong> The generated answer has 5 claims. The judge determines that 4 are in the chunks and 1 is an unsupported extrapolation.&lt;/p>
$$\text{Faithfulness} = \frac{4}{5} = 0{.}80$$
&lt;p>&lt;strong>Warning sign:&lt;/strong> faithfulness &amp;lt; 0.85 indicates that the LLM is generating content that goes beyond the context, which is to say it is hallucinating with superficial backing.&lt;/p>
&lt;h3 id="2-answer-relevance--relevance-of-the-answer">2. Answer Relevance — relevance of the answer&lt;/h3>
&lt;p>&lt;strong>Question:&lt;/strong> does the answer actually answer the question as posed?&lt;/p>
&lt;p>&lt;strong>Intuition:&lt;/strong> An answer that answers the question well &amp;ldquo;implies&amp;rdquo; that question. If you generate N hypothetical questions from the answer and measure their semantic similarity to the original question, you get a relevance signal.&lt;/p>
&lt;p>&lt;strong>Calculation:&lt;/strong>&lt;/p>
$$\text{AnswerRelevance} = \frac{1}{N} \sum_{i=1}^{N} \cos(\vec{q}_{\text{original}}, \vec{q}_{i}^{\text{generated}})$$
&lt;p>where $\vec{q}$ are question embeddings.&lt;/p>
&lt;p>&lt;strong>Example:&lt;/strong> For the question &amp;ldquo;Which Python versions does FastAPI support?&amp;rdquo; and an answer about web frameworks in general, the hypothetical questions generated will be along the lines of &amp;ldquo;what are the best web frameworks?&amp;rdquo;, with low cosine against the original question → low answer relevance.&lt;/p>
&lt;h3 id="3-context-precision--retrieval-precision">3. Context Precision — retrieval precision&lt;/h3>
&lt;p>&lt;strong>Question:&lt;/strong> of the K retrieved chunks, what proportion are genuinely relevant?&lt;/p>
&lt;p>&lt;strong>Calculation&lt;/strong> (weighted version):&lt;/p>
$$\text{ContextPrecision@K} = \frac{\sum_{k=1}^{K} \text{Precision@}k \cdot \mathbb{1}[\text{chunk}_k \text{ is relevant}]}{|\text{relevant chunks in top-K}|}$$
&lt;p>The most direct form: the LLM judge classifies each chunk as relevant or not for answering the question. Precision is the relevant fraction.&lt;/p>
&lt;p>&lt;strong>Example:&lt;/strong> 5 chunks are retrieved. The judge considers 3 relevant and 2 noise.&lt;/p>
$$\text{ContextPrecision} = \frac{3}{5} = 0{.}60$$
&lt;p>&lt;strong>Warning sign:&lt;/strong> precision &amp;lt; 0.6 indicates that retrieval is contaminating the context with information that can contradict or dilute the correct answer.&lt;/p>
&lt;h3 id="4-context-recall--retrieval-recall">4. Context Recall — retrieval recall&lt;/h3>
&lt;p>&lt;strong>Question:&lt;/strong> of all the facts needed to build the correct answer (ground truth), what proportion are covered by the retrieved chunks?&lt;/p>
&lt;p>&lt;strong>Calculation:&lt;/strong>&lt;/p>
$$\text{ContextRecall} = \frac{|\text{ground-truth claims attributable to some chunk}|}{|\text{total claims in ground truth}|}$$
&lt;p>This metric &lt;strong>requires ground truth&lt;/strong>, which is to say you need the golden dataset.&lt;/p>
&lt;p>&lt;strong>Example:&lt;/strong> The ground truth has 6 claims. The retrieved chunks cover 5 of them; the sixth is in a document that retrieval did not find.&lt;/p>
$$\text{ContextRecall} = \frac{5}{6} \approx 0{.}83$$
&lt;h3 id="5-noise-sensitivity--sensitivity-to-noise">5. Noise Sensitivity — sensitivity to noise&lt;/h3>
&lt;p>&lt;strong>Question:&lt;/strong> if you introduce irrelevant chunks into the context, how far does faithfulness fall?&lt;/p>
&lt;p>It is computed as the difference in faithfulness between the clean condition and the condition with controlled noise:&lt;/p>
$$\Delta\text{Faithfulness} = \text{Faithfulness}_{\text{clean}} - \text{Faithfulness}_{\text{noisy}}$$
&lt;p>A $\Delta > 0{.}15$ indicates that the prompt or the LLM are fragile against contaminated contexts, a problem that low context precision turns into a routine occurrence.&lt;/p>
&lt;hr>
&lt;h2 id="a-complete-worked-example">A complete worked example&lt;/h2>
&lt;h3 id="scenario">Scenario&lt;/h3>
&lt;p>&lt;strong>Question:&lt;/strong> &amp;ldquo;What latency guarantees does Redis offer for in-memory read operations with a 10 GB dataset?&amp;rdquo;&lt;/p>
&lt;p>&lt;strong>Answer generated by the RAG:&lt;/strong>&lt;/p>
&lt;blockquote>
&lt;p>&amp;ldquo;Redis guarantees sub-millisecond latencies for GET operations on datasets that fit in RAM. With a 10 GB dataset and clients on the same local network, P99 is typically below 1 ms. Redis also supports horizontal clustering to scale beyond the RAM of a single node. In addition, Redis has included native JSON support since version 7.2.&amp;rdquo;&lt;/p>
&lt;/blockquote>
&lt;p>&lt;strong>Ground truth (reference answer):&lt;/strong>&lt;/p>
&lt;blockquote>
&lt;p>&amp;ldquo;Redis operates entirely in memory, which guarantees sub-millisecond latencies for GET. On local networks with 10 GB datasets in RAM, P99 sits below 1 ms. Clustering allows scaling beyond the RAM of a single node.&amp;rdquo;&lt;/p>
&lt;/blockquote>
&lt;p>&lt;strong>Retrieved chunks (5 chunks, summarised fragments):&lt;/strong>&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>#&lt;/th>
&lt;th>Summarised content&lt;/th>
&lt;th>Relevant&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>C1&lt;/td>
&lt;td>&amp;ldquo;Redis operates in memory; GET has latencies &amp;lt; 1 ms on LAN&amp;rdquo;&lt;/td>
&lt;td>Yes&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>C2&lt;/td>
&lt;td>&amp;ldquo;Redis Cluster allows sharding to scale total RAM&amp;rdquo;&lt;/td>
&lt;td>Yes&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>C3&lt;/td>
&lt;td>&amp;ldquo;Redis Sentinel manages high availability through automatic failover&amp;rdquo;&lt;/td>
&lt;td>No&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>C4&lt;/td>
&lt;td>&amp;ldquo;Redis benchmarks: P50 = 0.3 ms, P99 = 0.9 ms on a 10 GB dataset&amp;rdquo;&lt;/td>
&lt;td>Yes&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>C5&lt;/td>
&lt;td>&amp;ldquo;Redis Stack adds modules: RedisJSON, RediSearch, RedisTimeSeries&amp;rdquo;&lt;/td>
&lt;td>No&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;h3 id="step-by-step-calculation">Step-by-step calculation&lt;/h3>
&lt;p>&lt;strong>Faithfulness:&lt;/strong>&lt;/p>
&lt;p>Claims in the generated answer:&lt;/p>
&lt;ol>
&lt;li>&amp;ldquo;Redis guarantees sub-millisecond latencies for GET on in-RAM datasets&amp;rdquo; → &lt;strong>supported&lt;/strong> by C1, C4&lt;/li>
&lt;li>&amp;ldquo;With 10 GB on LAN, P99 &amp;lt; 1 ms&amp;rdquo; → &lt;strong>supported&lt;/strong> by C4&lt;/li>
&lt;li>&amp;ldquo;Redis supports horizontal clustering to scale RAM&amp;rdquo; → &lt;strong>supported&lt;/strong> by C2&lt;/li>
&lt;li>&amp;ldquo;Redis has included native JSON support since version 7.2&amp;rdquo; → &lt;strong>NOT supported&lt;/strong> by any chunk (C5 mentions RedisJSON as a Redis Stack module, not as native to Redis core)&lt;/li>
&lt;/ol>
$$\text{Faithfulness} = \frac{3}{4} = 0{.}75$$
&lt;p>Claim 4 is an extrapolation that mixes information from C5 imprecisely, a partial hallucination.&lt;/p>
&lt;p>&lt;strong>Context Precision:&lt;/strong>&lt;/p>
&lt;p>Relevant chunks: C1, C2, C4 (3 out of 5).&lt;/p>
$$\text{ContextPrecision} = \frac{3}{5} = 0{.}60$$
&lt;p>C3 and C5 are noise. C5 in particular contributed to the partial hallucination about JSON.&lt;/p>
&lt;p>&lt;strong>Context Recall:&lt;/strong>&lt;/p>
&lt;p>Ground-truth claims:&lt;/p>
&lt;ol>
&lt;li>&amp;ldquo;Redis operates in memory, GET &amp;lt; 1 ms&amp;rdquo; → attributable to C1 ✓&lt;/li>
&lt;li>&amp;ldquo;P99 &amp;lt; 1 ms on LAN with 10 GB&amp;rdquo; → attributable to C4 ✓&lt;/li>
&lt;li>&amp;ldquo;Clustering scales beyond the RAM of a single node&amp;rdquo; → attributable to C2 ✓&lt;/li>
&lt;/ol>
$$\text{ContextRecall} = \frac{3}{3} = 1{.}00$$
&lt;p>Retrieval found every chunk needed for the ground truth. The problem is not recall but precision (C3 and C5 contaminated the context).&lt;/p>
&lt;p>&lt;strong>Answer Relevance:&lt;/strong>&lt;/p>
&lt;p>The judge generates 3 hypothetical questions from the answer:&lt;/p>
&lt;ul>
&lt;li>&amp;ldquo;What latencies does Redis offer for in-memory reads?&amp;rdquo; — cos = 0.91&lt;/li>
&lt;li>&amp;ldquo;How does Redis scale horizontally?&amp;rdquo; — cos = 0.74&lt;/li>
&lt;li>&amp;ldquo;Which JSON modules does Redis include?&amp;rdquo; — cos = 0.52 (a drift caused by the hallucination)&lt;/li>
&lt;/ul>
$$\text{AnswerRelevance} = \frac{0{.}91 + 0{.}74 + 0{.}52}{3} = 0{.}72$$
&lt;p>The drift towards JSON reduced relevance. A tighter answer would have scored around 0.90.&lt;/p>
&lt;h3 id="summary-of-the-example">Summary of the example&lt;/h3>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Metric&lt;/th>
&lt;th>Value&lt;/th>
&lt;th>Diagnosis&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Faithfulness&lt;/td>
&lt;td>0.75&lt;/td>
&lt;td>LLM extrapolated beyond the context&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Context Precision&lt;/td>
&lt;td>0.60&lt;/td>
&lt;td>Retrieval returned 2 irrelevant chunks&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Context Recall&lt;/td>
&lt;td>1.00&lt;/td>
&lt;td>Retrieval captured everything needed&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Answer Relevance&lt;/td>
&lt;td>0.72&lt;/td>
&lt;td>Answer drifts off topic&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>&lt;strong>Main corrective action:&lt;/strong> improve the reranker so that C3 and C5 are filtered before they reach the LLM. The faithfulness and relevance problem is a direct consequence of the low precision, not of the LLM itself.&lt;/p>
&lt;hr>
&lt;h2 id="building-the-golden-dataset">Building the golden dataset&lt;/h2>
&lt;h3 id="what-it-is-and-why-it-matters">What it is and why it matters&lt;/h3>
&lt;p>The golden dataset is a set of &lt;code>(question, relevant chunks, correct answer)&lt;/code> tuples that acts as a reference calibrator. Without it, context recall cannot be computed (there is no ground truth) and the other metrics lack any interpretive anchor: is 0.75 faithfulness good or bad for this corpus and this domain?&lt;/p>
&lt;p>A well-built golden dataset lets you:&lt;/p>
&lt;ul>
&lt;li>Compare pipeline versions (embedder v1 vs v2, chunk size 512 vs 1024)&lt;/li>
&lt;li>Detect regressions in CI before deploying&lt;/li>
&lt;li>Stratify the analysis by question type&lt;/li>
&lt;/ul>
&lt;h3 id="llm-assisted-construction-pipeline">LLM-assisted construction pipeline&lt;/h3>
&lt;p>Purely manual construction is expensive. The standard pattern in 2026 is LLM assistance with human review of a sample:&lt;/p>
&lt;p>&lt;strong>Step 1 — Seed chunk selection.&lt;/strong> From the full corpus, select representative chunks through stratified sampling (by section, date, document type). For a technical corpus of 10,000 chunks, 500-1,000 seeds is a reasonable starting point.&lt;/p>
&lt;p>&lt;strong>Step 2 — Question generation.&lt;/strong> A powerful LLM (Llama-3.1-70B or similar) generates 2-3 questions per seed chunk using a prompt along these lines:&lt;/p>
&lt;pre tabindex="0">&lt;code>Given the following documentation fragment, generate specific questions
that can only be answered correctly using THIS fragment and not
general knowledge. The questions should be the ones an engineer looking
for operational information would ask.
Fragment: {chunk}
&lt;/code>&lt;/pre>&lt;p>&lt;strong>Step 3 — Reference answer generation.&lt;/strong> The same LLM, with access to the seed chunk (and to adjacent chunks if the question requires them), generates the reference answer.&lt;/p>
&lt;p>&lt;strong>Step 4 — Human review of a sample.&lt;/strong> Manually review 10-20 % of the generated dataset. The most common rejection criteria: trivial questions that any LLM answers without the corpus, answers the LLM padded with parametric knowledge instead of the chunks, and badly formulated or ambiguous questions.&lt;/p>
&lt;h3 id="minimum-size">Minimum size&lt;/h3>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Use case&lt;/th>
&lt;th>Minimum pairs&lt;/th>
&lt;th>Notes&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Prototype / initial validation&lt;/td>
&lt;td>50-100&lt;/td>
&lt;td>Enough to spot coarse problems&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Technical corpus in production&lt;/td>
&lt;td>200-500&lt;/td>
&lt;td>Allows basic stratification&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Robust production with full stratification&lt;/td>
&lt;td>500-1,000+&lt;/td>
&lt;td>Needed to detect subtle regressions&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;h3 id="stratifying-the-dataset">Stratifying the dataset&lt;/h3>
&lt;p>A flat golden dataset measures the average but hides the extreme cases. The minimum recommended stratification includes three question types:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Easy (single-hop):&lt;/strong> A single chunk contains all the information needed. The baseline that any decent RAG must beat.&lt;/li>
&lt;li>&lt;strong>Hard (multi-hop):&lt;/strong> The correct answer requires combining information from 2-4 different chunks. This is where the limits of the reranker and the synthesis prompt show up.&lt;/li>
&lt;li>&lt;strong>Adversarial:&lt;/strong> The question has a false premise, or the corpus does not contain the answer. A correct RAG must answer &amp;ldquo;I do not have enough information&amp;rdquo;, whereas a fragile RAG hallucinates confidently. This question type directly measures the risk of high-impact hallucination.&lt;/li>
&lt;/ul>
&lt;h3 id="the-goodhart-trap">The Goodhart trap&lt;/h3>
&lt;blockquote>
&lt;p>&amp;ldquo;When a measure becomes a target, it ceases to be a good measure.&amp;rdquo; — Charles Goodhart&lt;/p>
&lt;/blockquote>
&lt;p>If you optimise the embedder or the reranker using the golden dataset as a loss function, the dataset is corrupted as a metric: the system learns to perform well on those specific questions without improving on the general domain.&lt;/p>
&lt;p>The solution is the same as in supervised ML: separate the &lt;strong>dev set&lt;/strong> (for optimisation and iteration) from the &lt;strong>test set&lt;/strong> (for final evaluation, frozen and audited). The test set must never be used to make design decisions; only to report the state of the system at release time.&lt;/p>
&lt;hr>
&lt;h2 id="correlation-with-real-satisfaction">Correlation with real satisfaction&lt;/h2>
&lt;p>Field studies published by the Databricks teams (2024) and the RAGAS adoption analyses (2025) point to interpretable operational thresholds:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Metric range&lt;/th>
&lt;th>Observable symptom&lt;/th>
&lt;th>Corrective action&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Faithfulness &amp;lt; 0.75&lt;/td>
&lt;td>Users frequently report &amp;ldquo;made-up answers&amp;rdquo;&lt;/td>
&lt;td>Review the LLM prompt; increase citation instructions; lower the temperature&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Faithfulness 0.75-0.85&lt;/td>
&lt;td>Occasional hallucinations on peripheral topics&lt;/td>
&lt;td>Improve context precision to eliminate contaminating chunks&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Faithfulness ≥ 0.85&lt;/td>
&lt;td>Correlates with positive NPS in field studies&lt;/td>
&lt;td>Hold steady; monitor for drift&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Context Precision &amp;lt; 0.60&lt;/td>
&lt;td>LLM includes contradictory information; inconsistent answers&lt;/td>
&lt;td>Tune the reranker; reduce K; review similarity thresholds&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Context Recall &amp;lt; 0.70&lt;/td>
&lt;td>Multi-hop questions fail; key information missing&lt;/td>
&lt;td>Review the chunking strategy; add larger chunks; enrich metadata&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Answer Relevance &amp;lt; 0.70&lt;/td>
&lt;td>Answers that are &amp;ldquo;correct but do not answer&amp;rdquo;&lt;/td>
&lt;td>Review the synthesis prompt; add an explicit instruction to stick to the question&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>Low context precision is especially pernicious: irrelevant chunks are not neutral. They raise the probability that the LLM uses incorrect information as though it were relevant, degrading faithfulness in a chain reaction. It is the transmission belt by which a retrieval problem turns into an LLM problem.&lt;/p>
&lt;hr>
&lt;h2 id="diagram-the-continuous-evaluation-loop">Diagram: the continuous evaluation loop&lt;/h2>
&lt;figure>
&lt;svg viewBox="0 0 800 420" xmlns="http://www.w3.org/2000/svg" role="img" aria-labelledby="rag-eval-title rag-eval-desc" style="width:100%;max-width:800px;font-family:system-ui,sans-serif;">
&lt;title id="rag-eval-title">RAG evaluation loop with RAGAS&lt;/title>
&lt;desc id="rag-eval-desc">Circular diagram showing the flow from corpus to corrective action via retrieval, LLM, answer, RAGAS judge and metrics with alerts.&lt;/desc>
&lt;!-- Background -->
&lt;rect width="800" height="420" fill="#0f1117" rx="12"/>
&lt;!-- Node: Corpus -->
&lt;rect x="30" y="170" width="110" height="50" rx="8" fill="#1e2433" stroke="#4a5568" stroke-width="1.5"/>
&lt;text x="85" y="191" text-anchor="middle" fill="#e2e8f0" font-size="12" font-weight="600">Corpus&lt;/text>
&lt;text x="85" y="207" text-anchor="middle" fill="#718096" font-size="10">documents&lt;/text>
&lt;!-- Arrow Corpus → Retrieval -->
&lt;line x1="140" y1="195" x2="175" y2="195" stroke="#4a5568" stroke-width="1.5" marker-end="url(#arrow)"/>
&lt;!-- Node: Retrieval -->
&lt;rect x="175" y="170" width="120" height="50" rx="8" fill="#1e2433" stroke="#4a5568" stroke-width="1.5"/>
&lt;text x="235" y="191" text-anchor="middle" fill="#e2e8f0" font-size="12" font-weight="600">Retrieval&lt;/text>
&lt;text x="235" y="207" text-anchor="middle" fill="#718096" font-size="10">top-K chunks&lt;/text>
&lt;!-- Arrow Retrieval → LLM -->
&lt;line x1="295" y1="195" x2="330" y2="195" stroke="#4a5568" stroke-width="1.5" marker-end="url(#arrow)"/>
&lt;!-- Node: LLM -->
&lt;rect x="330" y="170" width="110" height="50" rx="8" fill="#1e2433" stroke="#4a5568" stroke-width="1.5"/>
&lt;text x="385" y="191" text-anchor="middle" fill="#e2e8f0" font-size="12" font-weight="600">LLM&lt;/text>
&lt;text x="385" y="207" text-anchor="middle" fill="#718096" font-size="10">synthesis&lt;/text>
&lt;!-- Arrow LLM → Answer -->
&lt;line x1="440" y1="195" x2="475" y2="195" stroke="#4a5568" stroke-width="1.5" marker-end="url(#arrow)"/>
&lt;!-- Node: Answer -->
&lt;rect x="475" y="170" width="110" height="50" rx="8" fill="#1e2433" stroke="#4a5568" stroke-width="1.5"/>
&lt;text x="530" y="191" text-anchor="middle" fill="#e2e8f0" font-size="12" font-weight="600">Answer&lt;/text>
&lt;text x="530" y="207" text-anchor="middle" fill="#718096" font-size="10">generated&lt;/text>
&lt;!-- Arrow Answer → RAGAS -->
&lt;line x1="585" y1="195" x2="620" y2="195" stroke="#4a5568" stroke-width="1.5" marker-end="url(#arrow)"/>
&lt;!-- Node: RAGAS Judge -->
&lt;rect x="620" y="170" width="130" height="50" rx="8" fill="#1a2640" stroke="#3b82f6" stroke-width="2"/>
&lt;text x="685" y="191" text-anchor="middle" fill="#93c5fd" font-size="12" font-weight="600">RAGAS Judge&lt;/text>
&lt;text x="685" y="207" text-anchor="middle" fill="#6b9fd4" font-size="10">LLM-as-judge&lt;/text>
&lt;!-- Arrow RAGAS → Metrics (downwards) -->
&lt;line x1="685" y1="220" x2="685" y2="285" stroke="#3b82f6" stroke-width="1.5" marker-end="url(#arrow-blue)"/>
&lt;!-- Node: Metrics -->
&lt;rect x="615" y="285" width="140" height="60" rx="8" fill="#1a2a1a" stroke="#22c55e" stroke-width="1.5"/>
&lt;text x="685" y="305" text-anchor="middle" fill="#86efac" font-size="12" font-weight="600">Metrics&lt;/text>
&lt;text x="685" y="320" text-anchor="middle" fill="#4ade80" font-size="9">faithfulness · precision&lt;/text>
&lt;text x="685" y="333" text-anchor="middle" fill="#4ade80" font-size="9">recall · relevance&lt;/text>
&lt;!-- Arrow Metrics → Alert -->
&lt;line x1="615" y1="315" x2="510" y2="315" stroke="#22c55e" stroke-width="1.5" marker-end="url(#arrow-green)"/>
&lt;!-- Node: Grafana alert -->
&lt;rect x="390" y="285" width="120" height="60" rx="8" fill="#2a1a1a" stroke="#f97316" stroke-width="1.5"/>
&lt;text x="450" y="305" text-anchor="middle" fill="#fdba74" font-size="12" font-weight="600">Alert&lt;/text>
&lt;text x="450" y="320" text-anchor="middle" fill="#fb923c" font-size="9">Prometheus&lt;/text>
&lt;text x="450" y="333" text-anchor="middle" fill="#fb923c" font-size="9">Grafana&lt;/text>
&lt;!-- Arrow Alert → Action -->
&lt;line x1="390" y1="315" x2="285" y2="315" stroke="#f97316" stroke-width="1.5" marker-end="url(#arrow-orange)"/>
&lt;!-- Node: Action -->
&lt;rect x="155" y="285" width="130" height="60" rx="8" fill="#1a1a2a" stroke="#a855f7" stroke-width="1.5"/>
&lt;text x="220" y="305" text-anchor="middle" fill="#d8b4fe" font-size="12" font-weight="600">Corrective action&lt;/text>
&lt;text x="220" y="320" text-anchor="middle" fill="#c084fc" font-size="9">retrieval / chunking&lt;/text>
&lt;text x="220" y="333" text-anchor="middle" fill="#c084fc" font-size="9">prompt / fine-tuning&lt;/text>
&lt;!-- Arrow Action → Corpus (closing the loop) -->
&lt;line x1="155" y1="315" x2="85" y2="315" stroke="#a855f7" stroke-width="1.5"/>
&lt;line x1="85" y1="315" x2="85" y2="222" stroke="#a855f7" stroke-width="1.5" marker-end="url(#arrow-purple)"/>
&lt;!-- Golden Dataset (side input) -->
&lt;rect x="600" y="350" width="155" height="40" rx="6" fill="#1a2633" stroke="#64748b" stroke-width="1" stroke-dasharray="5,3"/>
&lt;text x="677" y="366" text-anchor="middle" fill="#94a3b8" font-size="10" font-weight="600">Golden Dataset&lt;/text>
&lt;text x="677" y="380" text-anchor="middle" fill="#64748b" font-size="9">ground truth for recall&lt;/text>
&lt;!-- Arrow Golden Dataset → Metrics -->
&lt;line x1="677" y1="350" x2="685" y2="347" stroke="#64748b" stroke-width="1" stroke-dasharray="4,3" marker-end="url(#arrow-gray)"/>
&lt;!-- Langfuse label -->
&lt;rect x="460" y="130" width="90" height="28" rx="5" fill="#1a2633" stroke="#64748b" stroke-width="1" stroke-dasharray="4,2"/>
&lt;text x="505" y="148" text-anchor="middle" fill="#94a3b8" font-size="9">Langfuse tracing&lt;/text>
&lt;!-- Langfuse line above -->
&lt;line x1="505" y1="158" x2="505" y2="170" stroke="#64748b" stroke-width="1" stroke-dasharray="3,2"/>
&lt;!-- Marker definitions -->
&lt;defs>
&lt;marker id="arrow" markerWidth="8" markerHeight="8" refX="6" refY="3" orient="auto">
&lt;path d="M0,0 L0,6 L8,3 z" fill="#4a5568"/>
&lt;/marker>
&lt;marker id="arrow-blue" markerWidth="8" markerHeight="8" refX="6" refY="3" orient="auto">
&lt;path d="M0,0 L0,6 L8,3 z" fill="#3b82f6"/>
&lt;/marker>
&lt;marker id="arrow-green" markerWidth="8" markerHeight="8" refX="6" refY="3" orient="auto">
&lt;path d="M0,0 L0,6 L8,3 z" fill="#22c55e"/>
&lt;/marker>
&lt;marker id="arrow-orange" markerWidth="8" markerHeight="8" refX="6" refY="3" orient="auto">
&lt;path d="M0,0 L0,6 L8,3 z" fill="#f97316"/>
&lt;/marker>
&lt;marker id="arrow-purple" markerWidth="8" markerHeight="8" refX="6" refY="3" orient="auto">
&lt;path d="M0,0 L0,6 L8,3 z" fill="#a855f7"/>
&lt;/marker>
&lt;marker id="arrow-gray" markerWidth="8" markerHeight="8" refX="6" refY="3" orient="auto">
&lt;path d="M0,0 L0,6 L8,3 z" fill="#64748b"/>
&lt;/marker>
&lt;/defs>
&lt;/svg>
&lt;figcaption style="text-align:center;font-size:0.85em;color:#718096;margin-top:0.5em">The continuous evaluation loop: corpus → retrieval → LLM → RAGAS judge → metrics → alert → corrective action → corpus.&lt;/figcaption>
&lt;/figure>
&lt;hr>
&lt;h2 id="the-2026-oss-stack-for-running-ragas-on-premise">The 2026 OSS stack for running RAGAS on-premise&lt;/h2>
&lt;h3 id="ragas-apache-20">ragas (Apache 2.0)&lt;/h3>
&lt;p>The &lt;code>ragas&lt;/code> library supports asynchronous evaluation and multiple LLM backends. Integrating vLLM as the judge removes any need to send data to external APIs, which is critical in environments with sensitive data.&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">ragas&lt;/span> &lt;span class="kn">import&lt;/span> &lt;span class="n">evaluate&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">ragas.metrics&lt;/span> &lt;span class="kn">import&lt;/span> &lt;span class="p">(&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">faithfulness&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">answer_relevancy&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">context_precision&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">context_recall&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="kn">from&lt;/span> &lt;span class="nn">langchain_openai&lt;/span> &lt;span class="kn">import&lt;/span> &lt;span class="n">ChatOpenAI&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">OpenAIEmbeddings&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"># Judge LLM pointing at on-premise vLLM&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">judge_llm&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">ChatOpenAI&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;meta-llama/Llama-3.1-70B-Instruct&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">base_url&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;http://vllm-service:8000/v1&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">api_key&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;sk-local&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="c1"># vLLM ignores the value but requires the field&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">embeddings&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">OpenAIEmbeddings&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;BAAI/bge-m3&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">base_url&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;http://embedding-service:8001/v1&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">api_key&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;sk-local&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="n">result&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">evaluate&lt;/span>&lt;span class="p">(&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">dataset&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">golden_dataset&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="c1"># HuggingFace Dataset with standard columns&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">metrics&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="n">faithfulness&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">answer_relevancy&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">context_precision&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">context_recall&lt;/span>&lt;span class="p">],&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">llm&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">judge_llm&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">embeddings&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">embeddings&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 dataset RAGAS expects has four columns: &lt;code>question&lt;/code>, &lt;code>answer&lt;/code>, &lt;code>contexts&lt;/code> (a list of strings), &lt;code>ground_truth&lt;/code>.&lt;/p>
&lt;h3 id="langfuse-for-eval-traceability">Langfuse for eval traceability&lt;/h3>
&lt;p>Each RAGAS evaluation is recorded in Langfuse as a &lt;em>dataset experiment&lt;/em>, linking the scores to the production spans (see https://blog.lo0.es/en/posts/llm-tracing-opentelemetry-genai/). This makes it possible to correlate a drop in faithfulness with the specific request that caused it. Without that link, the metrics are numbers with no actionable context.&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>&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">Langfuse&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"># Create or retrieve the dataset in Langfuse&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">dataset&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">lf&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">get_or_create_dataset&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;rag-golden-v3&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="c1"># Record the experiment scores&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">idx&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">row&lt;/span> &lt;span class="ow">in&lt;/span> &lt;span class="n">result&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">to_pandas&lt;/span>&lt;span class="p">()&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">iterrows&lt;/span>&lt;span class="p">():&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&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;ragas-faithfulness&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="n">row&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;faithfulness&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">trace_id&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">row&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="c1"># linked to the production span&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;h3 id="prometheus--grafana-for-operational-alerts">Prometheus + Grafana for operational alerts&lt;/h3>
&lt;p>RAGAS metrics are exposed as Prometheus gauges. A Grafana dashboard with thresholds configures alerts when faithfulness falls sustainedly below 0.80:&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"># Prometheus alert rule&lt;/span>&lt;span class="w">
&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">RAGFaithfulnessLow&lt;/span>&lt;span class="w">
&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">avg_over_time(rag_faithfulness_score[30m]) &amp;lt; 0.80&lt;/span>&lt;span class="w">
&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">10m&lt;/span>&lt;span class="w">
&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>&lt;/span>&lt;span class="line">&lt;span class="cl">&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;RAG faithfulness below threshold ({{ $value | humanize }})&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">description&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;Review context precision and the reranker. Possible corpus drift.&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h3 id="running-ragas-against-on-premise-vllm--practical-considerations">Running RAGAS against on-premise vLLM — practical considerations&lt;/h3>
&lt;ul>
&lt;li>&lt;strong>Judge size:&lt;/strong> Llama-3.1-70B as the judge produces results comparable to GPT-4 on faithfulness and context evaluation, according to the RAGAS 0.2 benchmarks (2025). Smaller models (8B-13B) degrade judge quality on multi-hop questions.&lt;/li>
&lt;li>&lt;strong>Throughput:&lt;/strong> On on-premise hardware with 4×H100 SXM (320 GB, NVLink), a run of 200 evaluations with Llama-3.1-70B takes roughly 8-12 minutes with batch_size=8 and vLLM in continuous batching mode.&lt;/li>
&lt;li>&lt;strong>Cost per evaluation:&lt;/strong> With no external API, the marginal cost is electricity plus GPU amortisation. With 4×H100 at around 3 kW sustained, a run of 200 evaluations costs &amp;lt; 0.10 € in energy at a typical industrial tariff.&lt;/li>
&lt;li>&lt;strong>Recommended frequency:&lt;/strong> weekly offline eval over the full golden dataset plus sampled online eval (5-10 % of production requests) with a subset of metrics that do not require ground truth (faithfulness, answer relevance).&lt;/li>
&lt;/ul>
&lt;hr>
&lt;h2 id="what-we-have-not-covered">What we have not covered&lt;/h2>
&lt;ul>
&lt;li>&lt;strong>Alternatives to RAGAS:&lt;/strong> TruLens (evaluation with modular feedbacks), DeepEval (programmatic assertions, pytest integration), ARES (Stanford framework with trained classifiers instead of LLM-as-judge), and the OpenAI evals framework. Each has different trade-offs in judge cost, reliability and ease of integration.&lt;/li>
&lt;li>&lt;strong>Continuous eval in production:&lt;/strong> automatically sampling real requests, anonymising them, running a subset of metrics without ground truth and using the result to detect system drift before users report it. It requires a data pipeline separate from the inference pipeline.&lt;/li>
&lt;li>&lt;strong>Multilingual eval:&lt;/strong> RAGAS with a judge in Spanish or Catalan over a non-English corpus has documented biases when the judge is a model trained fundamentally in English. The semantic similarity embeddings for answer relevance are especially sensitive to corpus language vs judge language.&lt;/li>
&lt;li>&lt;strong>A/B testing of RAG configurations:&lt;/strong> using RAGAS metrics as the success criterion in controlled experiments, chunk size 512 vs 1024, pure BM25 vs hybrid, cross-encoder reranker vs biencoder, with statistical significance computed over the golden dataset.&lt;/li>
&lt;/ul>
&lt;hr>
&lt;h2 id="see-also">See also&lt;/h2>
&lt;ul>
&lt;li>https://blog.lo0.es/en/posts/llm-as-judge-exam-marker-not-oracle/ — the LLM judge pattern that RAGAS uses to measure faithfulness claim by claim&lt;/li>
&lt;li>https://blog.lo0.es/en/posts/evals-llm-layer-after-tracing/ — the general evals framework in which RAGAS is the RAG specialisation&lt;/li>
&lt;li>https://blog.lo0.es/en/posts/reranker-hybrid-retrieval-five-chunks-llm-reads/ — the retrieval layer whose context precision and recall these metrics measure&lt;/li>
&lt;li>https://blog.lo0.es/en/posts/rag-corpus-curation-active-librarian/ — the corpus quality that context recall reflects&lt;/li>
&lt;li>https://blog.lo0.es/en/posts/llm-tracing-opentelemetry-genai/ — the production spans where Langfuse records the RAGAS scores&lt;/li>
&lt;li>https://blog.lo0.es/en/posts/data-versioning-llmops-dvc-lakefs-reproducible-golden-dataset/ — the golden dataset is a data artefact that needs versioning just like the corpus&lt;/li>
&lt;/ul>
&lt;hr>
&lt;h2 id="references">References&lt;/h2>
&lt;ol>
&lt;li>Es Shahul, et al. &lt;em>RAGAS: Automated Evaluation of Retrieval Augmented Generation&lt;/em>. arXiv:2309.15217 (2023). &lt;a href="https://arxiv.org/abs/2309.15217">https://arxiv.org/abs/2309.15217&lt;/a>&lt;/li>
&lt;li>RAGAS Documentation v0.2. &lt;em>Metrics Reference&lt;/em>. &lt;a href="https://docs.ragas.io/en/stable/concepts/metrics/">https://docs.ragas.io/en/stable/concepts/metrics/&lt;/a> (accessed June 2026)&lt;/li>
&lt;li>Langfuse. &lt;em>Dataset Experiments&lt;/em>. &lt;a href="https://langfuse.com/docs/datasets/overview">https://langfuse.com/docs/datasets/overview&lt;/a> (accessed June 2026)&lt;/li>
&lt;li>Databricks. &lt;em>LLM Quality Evaluation: From Lab to Production&lt;/em>. Databricks Engineering Blog (2024).&lt;/li>
&lt;li>Saad-Falcon, J. et al. &lt;em>ARES: An Automated Evaluation Framework for Retrieval-Augmented Generation Systems&lt;/em>. arXiv:2311.09476 (2023).&lt;/li>
&lt;li>Goodhart, C.A.E. &lt;em>Problems of Monetary Management: The U.K. Experience&lt;/em>. Papers in Monetary Economics. Reserve Bank of Australia (1975). Modern formulation of the law that bears his name.&lt;/li>
&lt;li>vLLM Project. &lt;em>OpenAI-Compatible Server&lt;/em>. &lt;a href="https://docs.vllm.ai/en/stable/serving/openai_compatible_server.html">https://docs.vllm.ai/en/stable/serving/openai_compatible_server.html&lt;/a> (accessed June 2026)&lt;/li>
&lt;/ol></description></item><item><title>LLM Guard: the sworn translator with a notebook of equivalences — anatomy, scanners and its integration with Langfuse, vLLM and LiteLLM</title><link>https://blog.lo0.es/en/posts/llm-guard-anatomy-scanners-langfuse-vllm-litellm/</link><pubDate>Mon, 01 Jun 2026 05:00:00 +0200</pubDate><guid>https://blog.lo0.es/en/posts/llm-guard-anatomy-scanners-langfuse-vllm-litellm/</guid><description>&lt;blockquote>
&lt;p>This post is a &lt;strong>deep dive into a single piece&lt;/strong> inside the layer covered in the &lt;a href="https://blog.lo0.es/en/posts/llm-guardrails-safety-four-lines-of-defence/">post on guardrails and LLM safety&lt;/a>. That one maps the four lines of defence (input, retrieval, tool, output) and the 2026 OSS catalogue from a bird&amp;rsquo;s-eye view; this one gets down to ground level on &lt;strong>LLM Guard&lt;/strong>, because its Anonymize/Deanonymize pattern, its composable scanner model and its four deployment modes deserve their own treatment. The analogies built up there (HACCP kitchen, four CCPs) still hold: this post zooms in on the tool that occupies the PII belt and the individual scanners within that architecture.&lt;/p>
&lt;/blockquote>
&lt;h2 id="tldr">TL;DR&lt;/h2>
&lt;p>LLM Guard is the OSS tool (MIT, Protect AI) that materialises the LLM guardrail layer with a model radically different from that of NeMo Guardrails and Guardrails AI: instead of a declarative DSL (Colang) or a validator framework with external LLM-as-judge calls, it offers a &lt;strong>catalogue of specialised compact detectors&lt;/strong>, 15 input scanners and 21 output scanners, composable as a Python pipeline, with one distinctive mechanism: the &lt;strong>Anonymize → LLM → Deanonymize with Vault&lt;/strong> pattern. The Vault is a centralised store of the mapping between real entities (&lt;code>John Doe&lt;/code>, &lt;code>12345678X&lt;/code>) and placeholders (&lt;code>[REDACTED_PERSON_1]&lt;/code>, &lt;code>[REDACTED_DNI_1]&lt;/code>); on input, entities are redacted and the mapping is saved; the LLM never sees real personal data; on output, the Deanonymize scanner restores the originals before returning the answer to the user. This post takes apart: the internal anatomy (Vault + scanners + orchestrator with &lt;code>fail_fast&lt;/code> and TTL cache), the four deployment patterns with their mathematics (in-process library, FastAPI API, OTel sidecar over vLLM, AI Gateway plugin — LiteLLM, Envoy AI Gateway, Kong AI Gateway), the integration diagrams with Langfuse (via LLM Guard&amp;rsquo;s OTel HTTP exporter + &lt;code>langfuse.score()&lt;/code> from the AI Gateway), the mathematics with the project&amp;rsquo;s benchmarks (Anonymize at 177 ms CPU → 128 ms ONNX-CPU → 125 ms GPU FP16 → 38 ms GPU+ONNX, scaling ×4.6 when you combine ONNX + GPU), the ONNX pattern as the default acceleration without a dedicated GPU, the comparison with NeMo Guardrails (declarative Colang DSL oriented to conversational flow) and Guardrails AI (JSON contract-style validators with external judges), the application to on-premise hardware (which scanners hold up on CPU, which need a shared GPU) and the seven operational pitfalls specific to the tool.&lt;/p>
&lt;h2 id="the-analogy-the-sworn-translator-with-a-notebook-of-equivalences">The analogy: the sworn translator with a notebook of equivalences&lt;/h2>
&lt;div class="diagram" style="max-width:820px;margin:1.5rem auto;">
&lt;svg viewBox="0 0 820 380" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="LLM Guard as a sworn translator with a notebook of equivalences">
&lt;style>
.t-user{fill:#7aafff;stroke:#444;stroke-width:1.4;rx:8}
.t-trad{fill:#ffd76b;stroke:#444;stroke-width:1.4;rx:8}
.t-model{fill:#ff8a4c;stroke:#444;stroke-width:1.4;rx:8}
.t-vault{fill:#c8b8ff;stroke:#444;stroke-width:1.4;rx:8}
.tl{font:600 13px sans-serif;fill:#222}
.ts{font:400 11px sans-serif;fill:#555}
.tn{font:italic 11px sans-serif;fill:#555}
.tar{stroke:#666;stroke-width:1.6;fill:none;marker-end:url(#mt1)}
.tcb{stroke:#7a5;stroke-width:1.4;fill:none;stroke-dasharray:5 3;marker-end:url(#mt2)}
&lt;/style>
&lt;defs>
&lt;marker id="mt1" 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="mt2" 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="#7a5"/>&lt;/marker>
&lt;/defs>
&lt;rect x="20" y="40" width="120" height="60" class="t-user"/>
&lt;text x="80" y="64" text-anchor="middle" class="tl">Customer&lt;/text>
&lt;text x="80" y="82" text-anchor="middle" class="ts">"My ID is 12345678X,&lt;/text>
&lt;text x="80" y="96" text-anchor="middle" class="ts">is VAT due?"&lt;/text>
&lt;rect x="180" y="40" width="160" height="60" class="t-trad"/>
&lt;text x="260" y="64" text-anchor="middle" class="tl">Translator (Anonymize)&lt;/text>
&lt;text x="260" y="82" text-anchor="middle" class="ts">redacts sensitive entities&lt;/text>
&lt;text x="260" y="96" text-anchor="middle" class="ts">+ writes them in the notebook&lt;/text>
&lt;rect x="380" y="40" width="160" height="60" class="t-model"/>
&lt;text x="460" y="64" text-anchor="middle" class="tl">LLM&lt;/text>
&lt;text x="460" y="82" text-anchor="middle" class="ts">receives sanitised text:&lt;/text>
&lt;text x="460" y="96" text-anchor="middle" class="ts">"My ID is [DNI_1], is VAT due?"&lt;/text>
&lt;rect x="580" y="40" width="160" height="60" class="t-trad"/>
&lt;text x="660" y="64" text-anchor="middle" class="tl">Translator (Deanonymize)&lt;/text>
&lt;text x="660" y="82" text-anchor="middle" class="ts">restores the originals&lt;/text>
&lt;text x="660" y="96" text-anchor="middle" class="ts">from the notebook&lt;/text>
&lt;rect x="680" y="40" width="120" height="60" class="t-user" transform="translate(-20 130)"/>
&lt;text x="720" y="194" text-anchor="middle" class="tl" transform="translate(-20 0)">Customer receives&lt;/text>
&lt;text x="720" y="212" text-anchor="middle" class="ts" transform="translate(-20 0)">an answer with&lt;/text>
&lt;text x="720" y="226" text-anchor="middle" class="ts" transform="translate(-20 0)">"12345678X" restored&lt;/text>
&lt;path class="tar" d="M140,70 L180,70"/>
&lt;path class="tar" d="M340,70 L380,70"/>
&lt;path class="tar" d="M540,70 L580,70"/>
&lt;path class="tar" d="M660,100 Q660,150 700,170"/>
&lt;rect x="280" y="220" width="220" height="80" class="t-vault"/>
&lt;text x="390" y="244" text-anchor="middle" class="tl">Vault (shared notebook)&lt;/text>
&lt;text x="390" y="262" text-anchor="middle" class="ts">[PERSON_1] = "Marta García"&lt;/text>
&lt;text x="390" y="276" text-anchor="middle" class="ts">[DNI_1] = "12345678X"&lt;/text>
&lt;text x="390" y="290" text-anchor="middle" class="ts">[IBAN_1] = "ES91 2100 0418..."&lt;/text>
&lt;path class="tcb" d="M260,100 L320,218"/>
&lt;path class="tcb" d="M460,218 L660,100"/>
&lt;text x="200" y="160" class="tn">stores mapping&lt;/text>
&lt;text x="500" y="160" class="tn">looks up to restore&lt;/text>
&lt;text x="410" y="340" text-anchor="middle" class="tn">The LLM never sees the original PII. The notebook (Vault) is the only point that knows the equivalence.&lt;/text>
&lt;/svg>
&lt;/div>
&lt;p>A serious sworn translator working with sensitive documents, an employment contract, a medical record, a tax return, does not send the raw text to the machine translation service in the cloud. They keep a &lt;strong>notebook of equivalences&lt;/strong> open on the desk. When the original document arrives, they open the notebook and start writing down: &amp;ldquo;Marta García&amp;rdquo; → &lt;code>[PERSON-1]&lt;/code>, &amp;ldquo;12345678X&amp;rdquo; → &lt;code>[DNI-1]&lt;/code>, &amp;ldquo;ES91 2100 0418&amp;hellip;&amp;rdquo; → &lt;code>[IBAN-1]&lt;/code>. They substitute each occurrence in the text with its label and pass the &lt;strong>anonymised&lt;/strong> text to the translation service. The service returns a translation that still contains the labels. The translator opens the notebook again, restores each label with its original value, and hands the client the final translation with the PII intact. As far as the translation service is concerned, those personal data &lt;strong>never existed&lt;/strong>: it only saw placeholders.&lt;/p>
&lt;p>This is the exact operation that defines LLM Guard&amp;rsquo;s character against the rest of the ecosystem. NeMo Guardrails solves safety with a &lt;strong>declarative graph&lt;/strong> of rules in Colang; Guardrails AI with &lt;strong>validators&lt;/strong> that invoke an LLM-as-judge to verify contracts; LLM Guard with a &lt;strong>catalogue of specialised compact detectors&lt;/strong> + the Vault pattern. All three are valid in different scenarios. The choice is not a matter of taste: it is structural, according to how the system is built and where the bottleneck is.&lt;/p>
&lt;p>The translator also checks, of course, that the text contains no other problems besides PII: insults, instructions to reprogram itself, links to hostile pages, code that should not be there. That is what the rest of the scanner catalogue is for. But the house signature, what sets it apart, is that notebook.&lt;/p>
&lt;h2 id="internal-anatomy-of-llm-guard">Internal anatomy of LLM Guard&lt;/h2>
&lt;div class="diagram" style="max-width:820px;margin:1.5rem auto;">
&lt;svg viewBox="0 0 820 460" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Internal anatomy of LLM Guard">
&lt;style>
.a-orch{fill:#7aafff;stroke:#444;stroke-width:1.4;rx:8}
.a-in{fill:#a8e6a3;stroke:#444;stroke-width:1.4;rx:6}
.a-out{fill:#ffd76b;stroke:#444;stroke-width:1.4;rx:6}
.a-vault{fill:#c8b8ff;stroke:#444;stroke-width:1.4;rx:6}
.a-obs{fill:#f8a8d8;stroke:#444;stroke-width:1.4;rx:6}
.al{font:600 12px sans-serif;fill:#222}
.as{font:400 10px sans-serif;fill:#444}
.an{font:italic 10px sans-serif;fill:#555}
.aar{stroke:#666;stroke-width:1.4;fill:none;marker-end:url(#ma1)}
&lt;/style>
&lt;defs>&lt;marker id="ma1" 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;rect x="20" y="20" width="780" height="40" class="a-orch"/>
&lt;text x="410" y="42" text-anchor="middle" class="al">Orchestrator: scan_prompt() / scan_output() · fail_fast · TTL cache · timeout · OTel spans&lt;/text>
&lt;text x="410" y="56" text-anchor="middle" class="as">Iterates scanners in order, aggregates is_valid and risk_score, emits traces and Prometheus metrics&lt;/text>
&lt;text x="50" y="90" class="al">Input scanners (15)&lt;/text>
&lt;rect x="30" y="100" width="120" height="22" class="a-in"/>&lt;text x="90" y="115" text-anchor="middle" class="as">Anonymize ⓥ&lt;/text>
&lt;rect x="30" y="125" width="120" height="22" class="a-in"/>&lt;text x="90" y="140" text-anchor="middle" class="as">PromptInjection&lt;/text>
&lt;rect x="30" y="150" width="120" height="22" class="a-in"/>&lt;text x="90" y="165" text-anchor="middle" class="as">Toxicity&lt;/text>
&lt;rect x="30" y="175" width="120" height="22" class="a-in"/>&lt;text x="90" y="190" text-anchor="middle" class="as">Secrets&lt;/text>
&lt;rect x="30" y="200" width="120" height="22" class="a-in"/>&lt;text x="90" y="215" text-anchor="middle" class="as">TokenLimit&lt;/text>
&lt;rect x="30" y="225" width="120" height="22" class="a-in"/>&lt;text x="90" y="240" text-anchor="middle" class="as">BanTopics&lt;/text>
&lt;rect x="30" y="250" width="120" height="22" class="a-in"/>&lt;text x="90" y="265" text-anchor="middle" class="as">BanCompetitors&lt;/text>
&lt;rect x="30" y="275" width="120" height="22" class="a-in"/>&lt;text x="90" y="290" text-anchor="middle" class="as">BanCode / Code&lt;/text>
&lt;rect x="30" y="300" width="120" height="22" class="a-in"/>&lt;text x="90" y="315" text-anchor="middle" class="as">Sentiment&lt;/text>
&lt;rect x="30" y="325" width="120" height="22" class="a-in"/>&lt;text x="90" y="340" text-anchor="middle" class="as">Gibberish&lt;/text>
&lt;rect x="30" y="350" width="120" height="22" class="a-in"/>&lt;text x="90" y="365" text-anchor="middle" class="as">Language&lt;/text>
&lt;rect x="30" y="375" width="120" height="22" class="a-in"/>&lt;text x="90" y="390" text-anchor="middle" class="as">InvisibleText&lt;/text>
&lt;rect x="30" y="400" width="120" height="22" class="a-in"/>&lt;text x="90" y="415" text-anchor="middle" class="as">Regex · BanSubstrings&lt;/text>
&lt;rect x="180" y="120" width="160" height="170" class="a-vault"/>
&lt;text x="260" y="142" text-anchor="middle" class="al">Vault&lt;/text>
&lt;text x="260" y="160" text-anchor="middle" class="as">In-memory dictionary&lt;/text>
&lt;text x="260" y="174" text-anchor="middle" class="as">per session / request&lt;/text>
&lt;text x="260" y="200" text-anchor="middle" class="as">[PERSON_1]→"Marta García"&lt;/text>
&lt;text x="260" y="214" text-anchor="middle" class="as">[DNI_1]→"12345678X"&lt;/text>
&lt;text x="260" y="228" text-anchor="middle" class="as">[IBAN_1]→"ES91..."&lt;/text>
&lt;text x="260" y="252" text-anchor="middle" class="as">.placeholder() / .get()&lt;/text>
&lt;text x="260" y="266" text-anchor="middle" class="as">optional: persistence&lt;/text>
&lt;text x="260" y="280" text-anchor="middle" class="as">Redis / sticky client&lt;/text>
&lt;path class="aar" d="M150,110 L186,140"/>
&lt;text x="360" y="90" class="al">Output scanners (21)&lt;/text>
&lt;rect x="350" y="100" width="120" height="22" class="a-out"/>&lt;text x="410" y="115" text-anchor="middle" class="as">Deanonymize ⓥ&lt;/text>
&lt;rect x="350" y="125" width="120" height="22" class="a-out"/>&lt;text x="410" y="140" text-anchor="middle" class="as">Sensitive (PII out)&lt;/text>
&lt;rect x="350" y="150" width="120" height="22" class="a-out"/>&lt;text x="410" y="165" text-anchor="middle" class="as">Toxicity · Bias&lt;/text>
&lt;rect x="350" y="175" width="120" height="22" class="a-out"/>&lt;text x="410" y="190" text-anchor="middle" class="as">NoRefusal&lt;/text>
&lt;rect x="350" y="200" width="120" height="22" class="a-out"/>&lt;text x="410" y="215" text-anchor="middle" class="as">Relevance&lt;/text>
&lt;rect x="350" y="225" width="120" height="22" class="a-out"/>&lt;text x="410" y="240" text-anchor="middle" class="as">FactualConsistency&lt;/text>
&lt;rect x="350" y="250" width="120" height="22" class="a-out"/>&lt;text x="410" y="265" text-anchor="middle" class="as">JSON validator&lt;/text>
&lt;rect x="350" y="275" width="120" height="22" class="a-out"/>&lt;text x="410" y="290" text-anchor="middle" class="as">MaliciousURLs&lt;/text>
&lt;rect x="350" y="300" width="120" height="22" class="a-out"/>&lt;text x="410" y="315" text-anchor="middle" class="as">URLReachability&lt;/text>
&lt;rect x="350" y="325" width="120" height="22" class="a-out"/>&lt;text x="410" y="340" text-anchor="middle" class="as">LanguageSame&lt;/text>
&lt;rect x="350" y="350" width="120" height="22" class="a-out"/>&lt;text x="410" y="365" text-anchor="middle" class="as">ReadingTime&lt;/text>
&lt;rect x="350" y="375" width="120" height="22" class="a-out"/>&lt;text x="410" y="390" text-anchor="middle" class="as">BanCompetitors&lt;/text>
&lt;rect x="350" y="400" width="120" height="22" class="a-out"/>&lt;text x="410" y="415" text-anchor="middle" class="as">Regex · BanSubstrings&lt;/text>
&lt;path class="aar" d="M340,140 L350,140"/>
&lt;text x="540" y="90" class="al">Backend models&lt;/text>
&lt;rect x="510" y="100" width="160" height="60" class="a-obs"/>
&lt;text x="590" y="120" text-anchor="middle" class="al">ONNX runtime&lt;/text>
&lt;text x="590" y="136" text-anchor="middle" class="as">quantised models&lt;/text>
&lt;text x="590" y="152" text-anchor="middle" class="as">CPU + GPU compatible&lt;/text>
&lt;rect x="510" y="170" width="160" height="60" class="a-obs"/>
&lt;text x="590" y="190" text-anchor="middle" class="al">Transformers (HF)&lt;/text>
&lt;text x="590" y="206" text-anchor="middle" class="as">BERT NER, distilbert&lt;/text>
&lt;text x="590" y="222" text-anchor="middle" class="as">deberta, bge, etc.&lt;/text>
&lt;rect x="510" y="240" width="160" height="60" class="a-obs"/>
&lt;text x="590" y="260" text-anchor="middle" class="al">Presidio Analyzer&lt;/text>
&lt;text x="590" y="276" text-anchor="middle" class="as">spaCy / flair / regex&lt;/text>
&lt;text x="590" y="292" text-anchor="middle" class="as">~50 base entities&lt;/text>
&lt;rect x="510" y="310" width="160" height="60" class="a-obs"/>
&lt;text x="590" y="330" text-anchor="middle" class="al">Pure validators&lt;/text>
&lt;text x="590" y="346" text-anchor="middle" class="as">regex, JSON schema,&lt;/text>
&lt;text x="590" y="362" text-anchor="middle" class="as">stdlib URL parsing&lt;/text>
&lt;text x="700" y="90" class="al">Telemetry&lt;/text>
&lt;rect x="690" y="100" width="115" height="60" class="a-obs"/>
&lt;text x="747" y="120" text-anchor="middle" class="al">OTel exporter&lt;/text>
&lt;text x="747" y="136" text-anchor="middle" class="as">traces (HTTP)&lt;/text>
&lt;text x="747" y="152" text-anchor="middle" class="as">metrics (HTTP)&lt;/text>
&lt;rect x="690" y="170" width="115" height="60" class="a-obs"/>
&lt;text x="747" y="190" text-anchor="middle" class="al">Prometheus&lt;/text>
&lt;text x="747" y="206" text-anchor="middle" class="as">/metrics endpoint&lt;/text>
&lt;text x="747" y="222" text-anchor="middle" class="as">counters + histograms&lt;/text>
&lt;rect x="690" y="240" width="115" height="60" class="a-obs"/>
&lt;text x="747" y="260" text-anchor="middle" class="al">structured logs&lt;/text>
&lt;text x="747" y="276" text-anchor="middle" class="as">stdout JSON,&lt;/text>
&lt;text x="747" y="292" text-anchor="middle" class="as">Loki/ELK parseable&lt;/text>
&lt;rect x="690" y="310" width="115" height="60" class="a-obs"/>
&lt;text x="747" y="330" text-anchor="middle" class="al">FastAPI&lt;/text>
&lt;text x="747" y="346" text-anchor="middle" class="as">/analyze/prompt&lt;/text>
&lt;text x="747" y="362" text-anchor="middle" class="as">/analyze/output&lt;/text>
&lt;text x="410" y="445" text-anchor="middle" class="an">The Vault is the unique piece: Anonymize (input) and Deanonymize (output) share it in the same request or session. Without it, PII would leak to the LLM.&lt;/text>
&lt;/svg>
&lt;/div>
&lt;p>The three structural pieces are:&lt;/p>
&lt;p>&lt;strong>1. The orchestrator&lt;/strong> (&lt;code>scan_prompt&lt;/code>, &lt;code>scan_output&lt;/code>). It receives an ordered list of scanners and runs them sequentially over the text. It returns the triple &lt;code>(sanitized_text, results_valid, results_score)&lt;/code> where:&lt;/p>
&lt;ul>
&lt;li>&lt;code>sanitized_text&lt;/code> is the text transformed by the scanners that mutate it (Anonymize, BanSubstrings with redaction).&lt;/li>
&lt;li>&lt;code>results_valid&lt;/code> is a &lt;code>{scanner_name: bool}&lt;/code> dict indicating which scanners passed.&lt;/li>
&lt;li>&lt;code>results_score&lt;/code> is a &lt;code>{scanner_name: float}&lt;/code> dict with the reported risk score (0 clean, 1 maximum violation).&lt;/li>
&lt;/ul>
&lt;p>It supports &lt;code>fail_fast=True&lt;/code> to stop after the first failure. It supports a per-scanner &lt;code>timeout&lt;/code> so it does not block on a slow detector. When exposed as a FastAPI API, it supports a TTL cache to avoid rescanning repeated prompts (the case of bots with identical questions).&lt;/p>
&lt;p>&lt;strong>2. The scanner catalogue.&lt;/strong> Fifteen input scanners and twenty-one output scanners, each with its own backend model and its configurable threshold:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Family&lt;/th>
&lt;th>Input&lt;/th>
&lt;th>Output&lt;/th>
&lt;th>Dominant backend&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>&lt;strong>PII&lt;/strong>&lt;/td>
&lt;td>Anonymize&lt;/td>
&lt;td>Deanonymize, Sensitive&lt;/td>
&lt;td>Presidio + BERT-NER&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Injection and jailbreak&lt;/strong>&lt;/td>
&lt;td>PromptInjection&lt;/td>
&lt;td>—&lt;/td>
&lt;td>Fine-tuned DeBERTa (Protect AI&amp;rsquo;s own)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Toxicity and bias&lt;/strong>&lt;/td>
&lt;td>Toxicity, Sentiment&lt;/td>
&lt;td>Toxicity, Bias, Sentiment&lt;/td>
&lt;td>Fine-tuned RoBERTa / BERT&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Banned topics&lt;/strong>&lt;/td>
&lt;td>BanTopics, BanCompetitors&lt;/td>
&lt;td>BanTopics, BanCompetitors&lt;/td>
&lt;td>Zero-shot BART-MNLI classifier&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Substrings and regex&lt;/strong>&lt;/td>
&lt;td>BanSubstrings, Regex&lt;/td>
&lt;td>BanSubstrings, Regex&lt;/td>
&lt;td>string matching + regex&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Secrets&lt;/strong>&lt;/td>
&lt;td>Secrets&lt;/td>
&lt;td>—&lt;/td>
&lt;td>detect-secrets (Yelp) + regex&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Structure&lt;/strong>&lt;/td>
&lt;td>TokenLimit, Language, InvisibleText, Gibberish&lt;/td>
&lt;td>JSON, Language, LanguageSame, Gibberish, ReadingTime&lt;/td>
&lt;td>tokeniser, lang-detect, JSON schema&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Code&lt;/strong>&lt;/td>
&lt;td>BanCode, Code&lt;/td>
&lt;td>BanCode, Code&lt;/td>
&lt;td>language classifier + regex&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>URLs&lt;/strong>&lt;/td>
&lt;td>—&lt;/td>
&lt;td>MaliciousURLs, URLReachability&lt;/td>
&lt;td>block list + DNS lookup&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Answer quality&lt;/strong>&lt;/td>
&lt;td>—&lt;/td>
&lt;td>NoRefusal, Relevance, FactualConsistency&lt;/td>
&lt;td>NLI cross-encoder + cosine similarity&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>Each scanner is imported and instantiated individually, with its own threshold:&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">llm_guard.input_scanners&lt;/span> &lt;span class="kn">import&lt;/span> &lt;span class="n">Anonymize&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">PromptInjection&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">Toxicity&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">Secrets&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">llm_guard.vault&lt;/span> &lt;span class="kn">import&lt;/span> &lt;span class="n">Vault&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">vault&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">Vault&lt;/span>&lt;span class="p">()&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">scanners&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">Anonymize&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">vault&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">threshold&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="mf">0.5&lt;/span>&lt;span class="p">),&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">PromptInjection&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">threshold&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="mf">0.85&lt;/span>&lt;span class="p">),&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">Toxicity&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">threshold&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="mf">0.7&lt;/span>&lt;span class="p">),&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">Secrets&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>&lt;strong>3. The Vault.&lt;/strong> A unique piece not found in NeMo Guardrails or Guardrails AI with the same model. It is an in-memory dictionary per session or request that stores the &lt;code>placeholder → original_value&lt;/code> mapping. The &lt;code>Anonymize&lt;/code> scanner writes it on input and the &lt;code>Deanonymize&lt;/code> scanner reads it on output. If the Vault is shared across multiple requests from the same user, the mapping persists (useful for multi-turn conversations). If it is per request, it is discarded after the answer.&lt;/p>
&lt;p>The basic Vault is a Python &lt;code>dict&lt;/code>; for distributed environments with multiple pods, it is replaced by a sticky Redis (same user → same pod) or by a custom Vault that reads from and writes to an external Redis, discarded after a TTL. This is operational, not part of the core library.&lt;/p>
&lt;h2 id="the-anonymize--llm--deanonymize-flow-in-detail">The Anonymize → LLM → Deanonymize flow in detail&lt;/h2>
&lt;p>The canonical usage pattern of LLM Guard breaks down into six exact steps:&lt;/p>
&lt;pre tabindex="0">&lt;code>1. Receive the user&amp;#39;s prompt:
&amp;#34;My name is Marta García and my IBAN is ES9121000418450200051332,
can you review the charge from 14 March?&amp;#34;
2. scan_prompt() with [Anonymize(vault), PromptInjection(), Toxicity()]
→ Anonymize redacts entities and stores them in the vault:
vault[&amp;#34;[REDACTED_PERSON_1]&amp;#34;] = &amp;#34;Marta García&amp;#34;
vault[&amp;#34;[REDACTED_IBAN_1]&amp;#34;] = &amp;#34;ES9121000418450200051332&amp;#34;
→ PromptInjection checks there is no jailbreak (there is none)
→ Toxicity checks there are no insults (there are none)
→ results_valid = {Anonymize: True, PromptInjection: True, Toxicity: True}
→ sanitized_prompt:
&amp;#34;My name is [REDACTED_PERSON_1] and my IBAN is [REDACTED_IBAN_1],
can you review the charge from 14 March?&amp;#34;
3. Call the LLM with sanitized_prompt:
→ vLLM receives the prompt with no real PII
→ generates the answer:
&amp;#34;Yes, [REDACTED_PERSON_1], I am going to review the charge on account
[REDACTED_IBAN_1]. Can you confirm the amount?&amp;#34;
4. scan_output() with [Deanonymize(vault), Toxicity(), Relevance(), Sensitive()]
→ Deanonymize replaces placeholders with values from the vault:
[REDACTED_PERSON_1] → &amp;#34;Marta García&amp;#34;
[REDACTED_IBAN_1] → &amp;#34;ES9121000418450200051332&amp;#34;
→ Toxicity checks the answer is not offensive
→ Relevance checks it answers the prompt
→ Sensitive checks no unauthorised PII appears
(in this case, the restored PII is authorised because the user
brought it themselves and the Vault signs it → the rule applies
only to new PII invented by the LLM)
→ sanitized_response:
&amp;#34;Yes, Marta García, I am going to review the charge on account
ES9121000418450200051332. Can you confirm the amount?&amp;#34;
5. Return sanitized_response to the user.
6. If the session continues, the vault persists and the next turns reuse
the same placeholders. When the session ends, the vault is discarded.
&lt;/code>&lt;/pre>&lt;p>Three details that matter operationally:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Persistent entities&lt;/strong> (&lt;code>[REDACTED_PERSON_1]&lt;/code> for &amp;ldquo;Marta García&amp;rdquo;) stay constant throughout the session. If the user mentions another person (&amp;ldquo;I spoke to Juan Pérez&amp;rdquo;), Anonymize will assign &lt;code>[REDACTED_PERSON_2]&lt;/code>. Cross-turn coherence is guaranteed by the Vault.&lt;/li>
&lt;li>&lt;strong>The LLM never sees the original data&lt;/strong> during the session. This is the key property for cases where the LLM is served from a cloud model or when the prompt is logged (Langfuse, OTel) without confidential access.&lt;/li>
&lt;li>&lt;strong>LLM Guard&amp;rsquo;s logging records the placeholders&lt;/strong>, not the original values. For audit with original values, an additional layer is needed (access to the Vault with privileged permissions), and this is by design, not by default.&lt;/li>
&lt;/ul>
&lt;h2 id="four-deployment-modes">Four deployment modes&lt;/h2>
&lt;h3 id="mode-1--in-process-python-library">Mode 1 — In-process Python library&lt;/h3>
&lt;p>The simplest: &lt;code>pip install llm-guard&lt;/code>, import the scanners in the application code, call &lt;code>scan_prompt&lt;/code>/&lt;code>scan_output&lt;/code> directly. The models load into the process. The advantage is minimum latency; the disadvantage is that each application replica loads its own models into memory.&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"># on the app server&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">llm_guard&lt;/span> &lt;span class="kn">import&lt;/span> &lt;span class="n">scan_prompt&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">scan_output&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">llm_guard.input_scanners&lt;/span> &lt;span class="kn">import&lt;/span> &lt;span class="n">Anonymize&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">PromptInjection&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">Toxicity&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">llm_guard.output_scanners&lt;/span> &lt;span class="kn">import&lt;/span> &lt;span class="n">Deanonymize&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">Toxicity&lt;/span> &lt;span class="k">as&lt;/span> &lt;span class="n">OutToxicity&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">Relevance&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">llm_guard.vault&lt;/span> &lt;span class="kn">import&lt;/span> &lt;span class="n">Vault&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">vault&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">Vault&lt;/span>&lt;span class="p">()&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">input_scanners&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="p">[&lt;/span>&lt;span class="n">Anonymize&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">vault&lt;/span>&lt;span class="p">),&lt;/span> &lt;span class="n">PromptInjection&lt;/span>&lt;span class="p">(),&lt;/span> &lt;span class="n">Toxicity&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_scanners&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="p">[&lt;/span>&lt;span class="n">Deanonymize&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">vault&lt;/span>&lt;span class="p">),&lt;/span> &lt;span class="n">OutToxicity&lt;/span>&lt;span class="p">(),&lt;/span> &lt;span class="n">Relevance&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"># in the request handler&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">sanitized_prompt&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">valid_in&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">score_in&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">scan_prompt&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">input_scanners&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">user_prompt&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="ow">not&lt;/span> &lt;span class="nb">all&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">valid_in&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">values&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">error_response&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">score_in&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">response&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">vllm_client&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">complete&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">sanitized_prompt&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">sanitized_resp&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">valid_out&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">score_out&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">scan_output&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">output_scanners&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">sanitized_prompt&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">response&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="ow">not&lt;/span> &lt;span class="nb">all&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">valid_out&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">values&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">error_response&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">score_out&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">return&lt;/span> &lt;span class="n">sanitized_resp&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>It fits &lt;strong>pattern A (sidecar)&lt;/strong> from the &lt;a href="https://blog.lo0.es/en/posts/llm-guardrails-safety-four-lines-of-defence/">guardrails post&lt;/a> when the app and the sidecar share a process. And &lt;strong>pattern C (in-process)&lt;/strong> if the app is itself the inference layer.&lt;/p>
&lt;h3 id="mode-2--its-own-fastapi-api">Mode 2 — Its own FastAPI API&lt;/h3>
&lt;p>The project includes a ready-made FastAPI server (&lt;code>llm-guard-api&lt;/code>) that exposes the scanners behind two REST endpoints:&lt;/p>
&lt;pre tabindex="0">&lt;code>POST /analyze/prompt
body: {&amp;#34;prompt&amp;#34;: &amp;#34;...&amp;#34;, &amp;#34;scanners&amp;#34;: [...] (optional)}
response: {&amp;#34;sanitized_prompt&amp;#34;: &amp;#34;...&amp;#34;, &amp;#34;is_valid&amp;#34;: bool, &amp;#34;scanners&amp;#34;: {scanner: {is_valid, risk_score}}}
POST /analyze/output
body: {&amp;#34;prompt&amp;#34;: &amp;#34;...&amp;#34;, &amp;#34;output&amp;#34;: &amp;#34;...&amp;#34;, &amp;#34;scanners&amp;#34;: [...]}
response: analogous
&lt;/code>&lt;/pre>&lt;p>Configuration through &lt;code>config/scanners.yml&lt;/code> with environment variables (&lt;code>SCAN_FAIL_FAST&lt;/code>, &lt;code>CACHE_MAX_SIZE&lt;/code>, &lt;code>CACHE_TTL&lt;/code>, &lt;code>SCAN_PROMPT_TIMEOUT&lt;/code>…). It carries Prometheus metrics at &lt;code>/metrics&lt;/code> and OTel HTTP exporter traces by default.&lt;/p>
&lt;p>It fits &lt;strong>pattern B (centralised service behind an AI gateway)&lt;/strong> from the guardrails post.&lt;/p>
&lt;h3 id="mode-3--otel-sidecar-on-the-inference-engine-pod">Mode 3 — OTel sidecar on the inference engine pod&lt;/h3>
&lt;p>For vLLM deployments on Kubernetes, a variant of mode 2 is to deploy the LLM Guard API as a &lt;strong>sidecar container&lt;/strong> in the same vLLM pod, talking over localhost. The AI gateway in front invokes the sidecar before and after inference. The node&amp;rsquo;s OTel collector aggregates vLLM&amp;rsquo;s spans with LLM Guard&amp;rsquo;s &lt;code>gen_ai.guardrail.*&lt;/code> spans automatically because they share a &lt;code>trace_id&lt;/code> propagated via HTTP baggage.&lt;/p>
&lt;p>This fits &lt;strong>pattern A (sidecar)&lt;/strong> from the guardrails post, but with the discipline of a REST API so as not to couple languages (the AI gateway can be Envoy in C++, LLM Guard in Python).&lt;/p>
&lt;h3 id="mode-4--plugin-inside-an-ai-gateway">Mode 4 — Plugin inside an AI gateway&lt;/h3>
&lt;p>Three AI gateways support LLM Guard as a native plugin in 2026:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>LiteLLM Proxy&lt;/strong> (MIT, BerriAI) — &lt;code>llm_guard&lt;/code> plugin, enabled in config with &lt;code>guardrails: [&amp;quot;llm_guard&amp;quot;]&lt;/code>. It calls the API internally.&lt;/li>
&lt;li>&lt;strong>Envoy AI Gateway&lt;/strong> (CNCF, Apache 2.0) — &lt;code>ai-guardrails&lt;/code> filter with a pluggable backend pointing at the LLM Guard service.&lt;/li>
&lt;li>&lt;strong>Kong AI Gateway&lt;/strong> (Apache 2.0) — &lt;code>ai-proxy&lt;/code> plugin with a post-processor that invokes LLM Guard.&lt;/li>
&lt;/ul>
&lt;p>In all three cases, the AI gateway is the single entry point from the client app to the LLM; the gateway calls LLM Guard before and after passing to the inference engine. Advantage: zero lock-in in the application code; switching from LLM Guard to NeMo Guardrails means changing the gateway plugin, not rewriting the app. Disadvantage: the extra hop adds latency (typically 5-15 ms intra-cluster).&lt;/p>
&lt;h2 id="graphical-integration-with-langfuse-vllm-and-the-otel-stack">Graphical integration with Langfuse, vLLM and the OTel stack&lt;/h2>
&lt;div class="diagram" style="max-width:820px;margin:1.5rem auto;">
&lt;svg viewBox="0 0 820 460" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Integration of LLM Guard with Langfuse, vLLM and the OTel stack">
&lt;style>
.b-app{fill:#7aafff;stroke:#444;stroke-width:1.4;rx:8}
.b-gw{fill:#a8e6a3;stroke:#444;stroke-width:1.4;rx:8}
.b-lg{fill:#ffd76b;stroke:#444;stroke-width:1.4;rx:8}
.b-vllm{fill:#ff8a4c;stroke:#444;stroke-width:1.4;rx:8}
.b-otel{fill:#c8b8ff;stroke:#444;stroke-width:1.4;rx:8}
.b-langfuse{fill:#f8a8d8;stroke:#444;stroke-width:1.4;rx:8}
.b-storage{fill:#f0e8c0;stroke:#444;stroke-width:1.4;rx:8}
.bl{font:600 13px sans-serif;fill:#222}
.bs{font:400 11px sans-serif;fill:#444}
.bn{font:italic 10px sans-serif;fill:#555}
.bar{stroke:#666;stroke-width:1.6;fill:none;marker-end:url(#mb1)}
.bart{stroke:#5a5;stroke-width:1.4;fill:none;stroke-dasharray:5 3;marker-end:url(#mb2)}
&lt;/style>
&lt;defs>
&lt;marker id="mb1" 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="mb2" 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="#5a5"/>&lt;/marker>
&lt;/defs>
&lt;rect x="20" y="40" width="140" height="50" class="b-app"/>
&lt;text x="90" y="60" text-anchor="middle" class="bl">Client app&lt;/text>
&lt;text x="90" y="78" text-anchor="middle" class="bs">chatbot · backend · agent&lt;/text>
&lt;rect x="200" y="40" width="180" height="50" class="b-gw"/>
&lt;text x="290" y="60" text-anchor="middle" class="bl">AI Gateway&lt;/text>
&lt;text x="290" y="78" text-anchor="middle" class="bs">LiteLLM · Envoy AI · Kong AI&lt;/text>
&lt;rect x="430" y="20" width="160" height="40" class="b-lg"/>
&lt;text x="510" y="38" text-anchor="middle" class="bl">LLM Guard API&lt;/text>
&lt;text x="510" y="54" text-anchor="middle" class="bs">scan_prompt + scan_output&lt;/text>
&lt;rect x="430" y="70" width="160" height="40" class="b-vllm"/>
&lt;text x="510" y="88" text-anchor="middle" class="bl">vLLM&lt;/text>
&lt;text x="510" y="104" text-anchor="middle" class="bs">inference engine + adapter&lt;/text>
&lt;rect x="640" y="40" width="160" height="50" class="b-storage"/>
&lt;text x="720" y="60" text-anchor="middle" class="bl">Vault Redis&lt;/text>
&lt;text x="720" y="78" text-anchor="middle" class="bs">placeholder→PII mapping&lt;/text>
&lt;path class="bar" d="M160,65 L200,65"/>
&lt;path class="bar" d="M380,55 L430,40"/>
&lt;path class="bar" d="M380,75 L430,90"/>
&lt;path class="bar" d="M510,60 L640,65"/>
&lt;text x="170" y="55" class="bn">1: prompt&lt;/text>
&lt;text x="390" y="35" class="bn">2: pre-scan&lt;/text>
&lt;text x="390" y="105" class="bn">3: inference&lt;/text>
&lt;text x="555" y="55" class="bn">vault R/W&lt;/text>
&lt;rect x="20" y="180" width="240" height="80" class="b-otel"/>
&lt;text x="140" y="202" text-anchor="middle" class="bl">OTel Collector (DaemonSet)&lt;/text>
&lt;text x="140" y="220" text-anchor="middle" class="bs">receives gen_ai.* and&lt;/text>
&lt;text x="140" y="234" text-anchor="middle" class="bs">gen_ai.guardrail.* spans from:&lt;/text>
&lt;text x="140" y="250" text-anchor="middle" class="bs">vLLM, LLM Guard, AI Gateway&lt;/text>
&lt;path class="bart" d="M510,110 Q260,140 140,178"/>
&lt;path class="bart" d="M510,60 Q330,140 200,178"/>
&lt;path class="bart" d="M290,90 Q230,140 140,178"/>
&lt;text x="320" y="135" class="bn">OTel HTTP spans&lt;/text>
&lt;rect x="300" y="180" width="200" height="80" class="b-langfuse"/>
&lt;text x="400" y="202" text-anchor="middle" class="bl">Langfuse&lt;/text>
&lt;text x="400" y="220" text-anchor="middle" class="bs">/api/public/otel ingestion&lt;/text>
&lt;text x="400" y="236" text-anchor="middle" class="bs">+ /api/public/scores&lt;/text>
&lt;text x="400" y="252" text-anchor="middle" class="bs">+ datasets + sessions&lt;/text>
&lt;path class="bar" d="M260,220 L300,220"/>
&lt;text x="270" y="215" class="bn">OTLP&lt;/text>
&lt;rect x="540" y="180" width="120" height="40" class="b-otel"/>
&lt;text x="600" y="200" text-anchor="middle" class="bl">Tempo / Jaeger&lt;/text>
&lt;text x="600" y="216" text-anchor="middle" class="bs">trace storage&lt;/text>
&lt;rect x="540" y="225" width="120" height="40" class="b-otel"/>
&lt;text x="600" y="245" text-anchor="middle" class="bl">VictoriaMetrics&lt;/text>
&lt;text x="600" y="261" text-anchor="middle" class="bs">Prom metrics&lt;/text>
&lt;path class="bar" d="M260,210 L540,200"/>
&lt;path class="bar" d="M260,235 L540,240"/>
&lt;rect x="700" y="180" width="100" height="80" class="b-storage"/>
&lt;text x="750" y="202" text-anchor="middle" class="bl">Grafana&lt;/text>
&lt;text x="750" y="220" text-anchor="middle" class="bs">datasource&lt;/text>
&lt;text x="750" y="234" text-anchor="middle" class="bs">Tempo + VM&lt;/text>
&lt;text x="750" y="252" text-anchor="middle" class="bs">+ Langfuse&lt;/text>
&lt;path class="bar" d="M660,220 L700,220"/>
&lt;rect x="20" y="320" width="780" height="50" class="b-gw"/>
&lt;text x="410" y="340" text-anchor="middle" class="bl">Langfuse scoring plane: the AI Gateway posts langfuse.score(trace_id, name="guardrail.PromptInjection", value=risk_score)&lt;/text>
&lt;text x="410" y="356" text-anchor="middle" class="bs">for each scanner run; that lets Langfuse build "% blocks per category" dashboards and time series&lt;/text>
&lt;path class="bar" d="M290,90 Q290,290 400,320"/>
&lt;text x="305" y="200" class="bn">HTTP scores&lt;/text>
&lt;text x="410" y="400" text-anchor="middle" class="bn">Three telemetry planes mix: traces (OTel → Tempo + Langfuse), metrics (Prometheus → VictoriaMetrics), scores (Langfuse SDK).&lt;/text>
&lt;text x="410" y="418" text-anchor="middle" class="bn">Grafana joins them by trace_id; Langfuse joins them by session_id + propagated trace_id.&lt;/text>
&lt;text x="410" y="438" text-anchor="middle" class="bn">The Vault Redis has its own data plane and is NOT exported to observability — the original PII never leaves it.&lt;/text>
&lt;/svg>
&lt;/div>
&lt;p>The &lt;strong>three integration routes with Langfuse&lt;/strong> that matter operationally:&lt;/p>
&lt;p>&lt;strong>Route A — LLM Guard&amp;rsquo;s OTel HTTP exporter.&lt;/strong> LLM Guard has a native OTel HTTP exporter. By setting &lt;code>OTEL_EXPORTER_OTLP_TRACES_ENDPOINT=https://langfuse.cluster/api/public/otel&lt;/code>, the &lt;code>gen_ai.guardrail.*&lt;/code> spans emitted by each scanner arrive directly at Langfuse and appear as child spans of the main LLM span (provided the &lt;code>trace_id&lt;/code> is propagated via HTTP baggage from the AI gateway). This is the canonical route in 2026.&lt;/p>
&lt;p>&lt;strong>Route B — Langfuse scoring API from the AI gateway.&lt;/strong> On receiving LLM Guard&amp;rsquo;s response with the per-scanner &lt;code>risk_score&lt;/code>, the AI gateway (LiteLLM, Envoy AI, Kong AI) issues a &lt;code>langfuse.score(trace_id, name=&amp;quot;guardrail.PromptInjection&amp;quot;, value=0.87, comment=&amp;quot;blocked&amp;quot;)&lt;/code> call for each scanner. In Langfuse it appears as scores attached to the same trace as the inference. It enables &amp;ldquo;blocks per category&amp;rdquo; dashboards and per-scanner time series. It is &lt;strong>complementary&lt;/strong> to route A: A brings the spans, B brings the numeric score that is easy to aggregate in SQL.&lt;/p>
&lt;p>&lt;strong>Route C — Langfuse sessions + Vault metadata.&lt;/strong> In conversational mode, the AI gateway propagates &lt;code>langfuse_session_id&lt;/code> to the Vault as its key. When a user has a multi-turn session, Langfuse shows the complete trace of the session, with the placeholders reused turn after turn. The original PII still never travels to Langfuse, only the placeholders and their categories.&lt;/p>
&lt;p>The node&amp;rsquo;s &lt;strong>OTel Collector&lt;/strong> is the glue: it receives spans from vLLM (via OpenLLMetry or native instrumentation), from LLM Guard (via its OTel exporter) and from the AI gateway (standard HTTP instrumentation), &lt;strong>joins them by trace_id&lt;/strong>, and sends them in parallel to Langfuse (via OTLP HTTP) and to Tempo/Jaeger. LLM Guard&amp;rsquo;s Prometheus metrics go to VictoriaMetrics via normal scraping. Grafana offers the unified view for cross-trace investigation; Langfuse offers the LLM-centric view with sessions and scores. The &lt;a href="https://blog.lo0.es/en/posts/llm-tracing-opentelemetry-genai/">OTel GenAI tracing post&lt;/a> details the complete mechanics of the Collector.&lt;/p>
&lt;h2 id="the-mathematics-that-matter">The mathematics that matter&lt;/h2>
&lt;h3 id="latency-per-scanner--the-real-numbers">Latency per scanner — the real numbers&lt;/h3>
&lt;p>The project publishes reproducible benchmarks. For the Anonymize scanner (input length 317 chars, batch 5), the reference data are:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Platform&lt;/th>
&lt;th>Backend&lt;/th>
&lt;th>Avg latency&lt;/th>
&lt;th>p99&lt;/th>
&lt;th>QPS&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>AWS m5.xlarge (CPU)&lt;/td>
&lt;td>Transformers&lt;/td>
&lt;td>177 ms&lt;/td>
&lt;td>326 ms&lt;/td>
&lt;td>1,789&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>AWS m5.xlarge (CPU)&lt;/td>
&lt;td>&lt;strong>ONNX runtime&lt;/strong>&lt;/td>
&lt;td>&lt;strong>128 ms&lt;/strong>&lt;/td>
&lt;td>180 ms&lt;/td>
&lt;td>2,464&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>AWS r6a.xlarge (AMD CPU)&lt;/td>
&lt;td>Transformers&lt;/td>
&lt;td>244 ms&lt;/td>
&lt;td>284 ms&lt;/td>
&lt;td>1,298&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>AWS g5.xlarge (NVIDIA A10G)&lt;/td>
&lt;td>Transformers FP16&lt;/td>
&lt;td>125 ms&lt;/td>
&lt;td>498 ms&lt;/td>
&lt;td>2,532&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>AWS g5.xlarge (A10G)&lt;/td>
&lt;td>&lt;strong>ONNX + GPU&lt;/strong>&lt;/td>
&lt;td>&lt;strong>38 ms&lt;/strong>&lt;/td>
&lt;td>99 ms&lt;/td>
&lt;td>8,317&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>Three operational observations:&lt;/p>
&lt;ol>
&lt;li>&lt;strong>ONNX always wins.&lt;/strong> Even on CPU, ONNX brings the average down from 177 to 128 ms (a factor of 1.4×). On GPU with ONNX, it drops from 177 to 38 ms (a factor of 4.6×). The practical rule: &lt;strong>always export the scanner&amp;rsquo;s model to ONNX before production&lt;/strong>. The official SaaS preview uses it by default.&lt;/li>
&lt;li>&lt;strong>A GPU without ONNX does not perform as well as you might expect.&lt;/strong> An A10G without ONNX (125 ms) is comparable to an m5.xlarge with ONNX (128 ms). The GPU alone does not compensate if the inference graph is not optimised. The relevant pairing is ONNX + GPU.&lt;/li>
&lt;li>&lt;strong>p99 latency without ONNX explodes.&lt;/strong> On GPU without ONNX, the p99 of 498 ms triples the average of 125 ms — queues and batching produce high tail latencies. With ONNX, the p99/avg ratio drops to 2.6× (99/38), far more predictable.&lt;/li>
&lt;/ol>
&lt;p>For a guardrail layer with five scanners run sequentially (Anonymize, PromptInjection, Toxicity, Secrets, BanTopics), the sum of the p99s is what determines the budget for line 1 (input). Five scanners at ~100 ms p99 each = 500 ms accumulated p99, outside the budget for interactive chat. With ONNX we get down to ~50 ms each = 250 ms p99, which is manageable. &lt;strong>With &lt;code>fail_fast=True&lt;/code>&lt;/strong>, the expected time is lower (the most likely case is that the cheap ones pass and the expensive ones only fail if they run at all).&lt;/p>
&lt;p>For a finer calculation, the expected latency of the pipeline with &lt;code>fail_fast&lt;/code> is:&lt;/p>
\[
\mathbb{E}[L] = \sum_{i=1}^{N} L_i \cdot \prod_{j=1}^{i-1} p_j
\]
&lt;p>where \(L_i\) is the latency of scanner \(i\) and \(p_j\) the probability that scanner \(j\) returns valid. On well-behaved traffic (most prompts pass every scanner), \(\prod p_j \approx 1\) and the formula collapses to the direct sum. On adversarial traffic, the faster scanners at the start of the pipeline cut in earlier and the expected latency drops sharply.&lt;/p>
&lt;h3 id="computational-cost-per-scanner">Computational cost per scanner&lt;/h3>
&lt;p>The size of the backend model determines the cost and the possibility of running on CPU vs requiring a GPU:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Scanner&lt;/th>
&lt;th>Typical backend model&lt;/th>
&lt;th>Parameters&lt;/th>
&lt;th>VRAM FP16 / ONNX-INT8&lt;/th>
&lt;th>CPU viable&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Anonymize (BERT-NER)&lt;/td>
&lt;td>dslim/bert-base-NER&lt;/td>
&lt;td>110 M&lt;/td>
&lt;td>220 MB / 55 MB&lt;/td>
&lt;td>Yes (with ONNX)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Anonymize (BERT-large)&lt;/td>
&lt;td>dslim/bert-large-NER&lt;/td>
&lt;td>335 M&lt;/td>
&lt;td>670 MB / 170 MB&lt;/td>
&lt;td>Yes but slow (~500 ms CPU)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>PromptInjection&lt;/td>
&lt;td>Fine-tuned DeBERTa-v3-base&lt;/td>
&lt;td>184 M&lt;/td>
&lt;td>370 MB / 90 MB&lt;/td>
&lt;td>Yes (with ONNX)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Toxicity&lt;/td>
&lt;td>unitary/toxic-bert&lt;/td>
&lt;td>110 M&lt;/td>
&lt;td>220 MB / 55 MB&lt;/td>
&lt;td>Yes&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Sentiment&lt;/td>
&lt;td>distilbert-sst2&lt;/td>
&lt;td>67 M&lt;/td>
&lt;td>130 MB / 35 MB&lt;/td>
&lt;td>Yes&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Gibberish&lt;/td>
&lt;td>small distilbert&lt;/td>
&lt;td>67 M&lt;/td>
&lt;td>130 MB / 35 MB&lt;/td>
&lt;td>Yes&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>BanTopics&lt;/td>
&lt;td>BART-MNLI zero-shot&lt;/td>
&lt;td>407 M&lt;/td>
&lt;td>815 MB / 200 MB&lt;/td>
&lt;td>Slow on CPU (~400 ms)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Bias (output)&lt;/td>
&lt;td>RoBERTa-bias&lt;/td>
&lt;td>125 M&lt;/td>
&lt;td>250 MB / 65 MB&lt;/td>
&lt;td>Yes&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>FactualConsistency&lt;/td>
&lt;td>cross-encoder/nli-deberta&lt;/td>
&lt;td>184 M&lt;/td>
&lt;td>370 MB / 90 MB&lt;/td>
&lt;td>Yes&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Relevance&lt;/td>
&lt;td>sentence-transformers&lt;/td>
&lt;td>110 M&lt;/td>
&lt;td>220 MB / 55 MB&lt;/td>
&lt;td>Yes&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>TokenLimit, Regex, JSON, BanSubstrings, Secrets&lt;/td>
&lt;td>(no model)&lt;/td>
&lt;td>—&lt;/td>
&lt;td>0&lt;/td>
&lt;td>Trivial&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>&lt;strong>Sensible on-premise pattern&lt;/strong>: model-free scanners (TokenLimit, Regex, BanSubstrings, Secrets) run on CPU without blinking. Anonymize, PromptInjection, Toxicity, Sentiment and Relevance run comfortably on CPU with ONNX-INT8 at ~50-150 ms p99. BanTopics and the ones based on large cross-encoders (FactualConsistency) are the candidates to live on a shared GPU if you want p99 &amp;lt; 100 ms.&lt;/p>
&lt;h3 id="api-throughput-on-a-cluster">API throughput on a cluster&lt;/h3>
&lt;p>One instance of the FastAPI API with 4 Uvicorn workers on a node with 8 vCPUs reaches ~600-1,200 RPS over a typical 5-scanner pipeline on CPU + ONNX. To scale:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Horizontally&lt;/strong>: replicate pods behind a ClusterIP Service — linear scaling, because the scanners are stateless (except the Vault, which is per session and is externalised to Redis if you want it sticky or shared).&lt;/li>
&lt;li>&lt;strong>Vertically with GPU&lt;/strong>: 1 H100 serves ~5,000-10,000 RPS with all the scanners in ONNX-GPU. It is overkill for most deployments except multi-tenant ones with thousands of sustained QPS.&lt;/li>
&lt;/ul>
&lt;p>The practical rule from the &lt;a href="https://blog.lo0.es/en/posts/llm-guardrails-safety-four-lines-of-defence/">guardrails post&lt;/a> (1 guardrail GPU per 4-8 LLM GPUs) holds here: with a 4×H100 SXM cluster serving Llama 70B at TP=4, an L4 or RTX 4090 dedicated to the LLM Guard service covers the load.&lt;/p>
&lt;h2 id="comparison-with-nemo-guardrails-and-guardrails-ai">Comparison with NeMo Guardrails and Guardrails AI&lt;/h2>
&lt;p>The three tools solve the same problem from three different architectural models. The choice between them is not about quality, since all three are mature, it is about &lt;strong>fit with the rest of the stack&lt;/strong>:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Dimension&lt;/th>
&lt;th>LLM Guard&lt;/th>
&lt;th>NeMo Guardrails&lt;/th>
&lt;th>Guardrails AI&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>&lt;strong>Conceptual model&lt;/strong>&lt;/td>
&lt;td>Pipeline of compact scanners&lt;/td>
&lt;td>Declarative Colang graph (conversational flow)&lt;/td>
&lt;td>JSON contract-style validators&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Dominant detection&lt;/strong>&lt;/td>
&lt;td>Specialised ML models (BERT, DeBERTa) per category&lt;/td>
&lt;td>Rules + LLM-as-judge&lt;/td>
&lt;td>Heuristic validators + external LLM-as-judge&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>PII workflow&lt;/strong>&lt;/td>
&lt;td>Anonymize + Vault + Deanonymize&lt;/td>
&lt;td>Via integrated Presidio, no built-in Vault&lt;/td>
&lt;td>PII validators, no automatic restitution&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Licence&lt;/strong>&lt;/td>
&lt;td>MIT&lt;/td>
&lt;td>Apache 2.0&lt;/td>
&lt;td>Apache 2.0 (+ paid Hub)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Language&lt;/strong>&lt;/td>
&lt;td>Python&lt;/td>
&lt;td>Python + Colang DSL&lt;/td>
&lt;td>Python&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>API maturity&lt;/strong>&lt;/td>
&lt;td>Built-in FastAPI API, built-in OTel&lt;/td>
&lt;td>Built-in FastAPI server, partial OTel&lt;/td>
&lt;td>External API server&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Cluster deployment&lt;/strong>&lt;/td>
&lt;td>Lib + API + sidecar + gateway plugins&lt;/td>
&lt;td>Lib + server&lt;/td>
&lt;td>Lib + server + SaaS Hub&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Typical latency (5 ONNX-GPU scanners)&lt;/strong>&lt;/td>
&lt;td>50-200 ms&lt;/td>
&lt;td>100-500 ms (more if there is an LLM judge)&lt;/td>
&lt;td>100-300 ms (depends on the validator)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>When it shines&lt;/strong>&lt;/td>
&lt;td>Apps with heavy PII, multi-tenant with sessions, GDPR/HIPAA requirements&lt;/td>
&lt;td>Conversational systems with defined flows, agents with a dialogue policy&lt;/td>
&lt;td>Apps with strict JSON contracts, structured output with additional validation&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>When it does not fit&lt;/strong>&lt;/td>
&lt;td>If you need a declarative dialogue policy&lt;/td>
&lt;td>If you want compact detectors with no LLM judge&lt;/td>
&lt;td>If you want an automatic Vault and Deanonymize&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>The three are &lt;strong>complementary in large deployments&lt;/strong>. A mature pattern in 2026 is:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>NeMo Guardrails&lt;/strong> orchestrates the dialogue flow (which tools the agent can invoke, in what order, with what cooldowns).&lt;/li>
&lt;li>&lt;strong>LLM Guard&lt;/strong> occupies the PII line + compact scanners on input and output, with its Vault doing the dirty work of anonymisation.&lt;/li>
&lt;li>&lt;strong>Guardrails AI&lt;/strong> validates structured outputs (JSON Schema, function calling) with its validators.&lt;/li>
&lt;/ul>
&lt;p>The separation of responsibilities avoids overlap and allows pieces to be swapped without rewriting everything. All three expose a FastAPI API and emit OTel spans; the AI gateway orchestrates them sequentially.&lt;/p>
&lt;h2 id="applied-to-on-premise-hardware">Applied to on-premise hardware&lt;/h2>
&lt;h3 id="on-the-rtx-4090-24-gb">On the RTX 4090 (24 GB)&lt;/h3>
&lt;p>A 4090 dedicated to the LLM Guard service pod comfortably serves the complete pipeline in medium-scale production:&lt;/p>
&lt;ul>
&lt;li>Anonymize (BERT-NER ONNX-INT8): ~50 MB VRAM.&lt;/li>
&lt;li>PromptInjection (DeBERTa ONNX-INT8): ~90 MB.&lt;/li>
&lt;li>Toxicity, Sentiment, Gibberish: ~150 MB total.&lt;/li>
&lt;li>BanTopics (BART-MNLI ONNX-INT8): ~200 MB.&lt;/li>
&lt;li>Bias, Relevance, FactualConsistency (output): ~250 MB total.&lt;/li>
&lt;/ul>
&lt;p>Total ~750 MB. The rest of the VRAM is idle or usable for aggressive batching. Sustained throughput of 3,000-6,000 RPS over the complete pipeline. For deployments with &amp;lt; 500 sustained RPS, the 4090 is under-used and can be shared with another workload (RAG embeddings, BGE reranker).&lt;/p>
&lt;h3 id="on-the-4h100-sxm-cluster-320-gb-total-nvlink">On the 4×H100 SXM cluster (320 GB total, NVLink)&lt;/h3>
&lt;p>There is capacity to spare by an order of magnitude. Sensible pattern:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>3 H100s&lt;/strong> serving the main LLM at TP=3 (Llama 70B FP8).&lt;/li>
&lt;li>&lt;strong>1 H100 split into MIG instances&lt;/strong> (1g.10gb or similar) — one slice for LLM Guard (~10 GB MIG is more than enough), another for the reranker, another for embeddings.&lt;/li>
&lt;/ul>
&lt;p>Aggregate throughput for LLM Guard at that scale: 15,000-30,000 RPS. Plenty for a large multi-tenant setup with long sessions.&lt;/p>
&lt;h2 id="the-specific-operational-pitfalls">The specific operational pitfalls&lt;/h2>
&lt;p>&lt;strong>Pitfall 1 — Vault with no TTL.&lt;/strong> The Vault grows without limit if it is not cleaned. In in-process lib mode per request there is no problem (the object is destroyed). In centralised service mode with Redis, &lt;strong>the TTL is missing&lt;/strong> and Redis fills up. A silent trap discovered when the Redis pod gets OOM-killed in production after six weeks.&lt;/p>
&lt;p>&lt;strong>Pitfall 2 — Vault not shared between pods + AI gateway with no sticky session.&lt;/strong> If the AI gateway distributes round-robin across multiple LLM Guard pods, one pod&amp;rsquo;s local Vault knows nothing of the mapping created by another. Result: on turn 2 of a session, Deanonymize does not find the placeholders from turn 1 and leaves a literal &lt;code>[REDACTED_PERSON_1]&lt;/code> in the answer. Solution: a shared Redis Vault &lt;strong>or&lt;/strong> a sticky session by user_id.&lt;/p>
&lt;p>&lt;strong>Pitfall 3 — Models not exported to ONNX in production.&lt;/strong> It is deployed with the default config (Transformers) and latency is 3-5× worse than the benchmarks report. The team assumes LLM Guard &amp;ldquo;is slow&amp;rdquo;. The solution is to export to ONNX (built into the project) and configure &lt;code>recognizer_conf&lt;/code> with the path to the model&amp;rsquo;s &lt;code>.onnx&lt;/code>.&lt;/p>
&lt;p>&lt;strong>Pitfall 4 — &lt;code>fail_fast=False&lt;/code> with many scanners.&lt;/strong> Without &lt;code>fail_fast&lt;/code>, every scanner always runs, even if the first one already blocked. Latency is 3-5× worse on adversarial traffic. For production, barring an explicit reason (wanting complete per-scanner metrics even when blocking), &lt;code>fail_fast=True&lt;/code> is the reasonable default.&lt;/p>
&lt;p>&lt;strong>Pitfall 5 — Infinite &lt;code>cache_ttl&lt;/code> + prompts with variable PII.&lt;/strong> If the API&amp;rsquo;s cache stores the &lt;code>sanitized_prompt&lt;/code> indefinitely, two different sessions with the same prompt structure but different PII can collide if the cache key does not include the Vault hash. You have to verify that the cache key includes either the complete content (without PII) or a hash of the original prompt.&lt;/p>
&lt;p>&lt;strong>Pitfall 6 — Structured logs with original PII.&lt;/strong> LLM Guard&amp;rsquo;s JSON stdout logs record only placeholders by default. But if custom hooks are added for debugging, it is easy to leak the original PII to the log. A regulatory audit (GDPR, ENS) detects this and it is a breach. Discipline: never add hooks that read from the Vault without explicit permission.&lt;/p>
&lt;p>&lt;strong>Pitfall 7 — &lt;code>scan_output&lt;/code> without the original &lt;code>prompt&lt;/code>.&lt;/strong> The &lt;code>scan_output&lt;/code> method expects (&lt;code>prompt&lt;/code>, &lt;code>output&lt;/code>) for validators that compare both (Relevance, LanguageSame, FactualConsistency). If only the output is passed, those scanners fail silently or return &lt;code>is_valid=True&lt;/code> by default. You have to keep the &lt;code>sanitized_prompt&lt;/code> in the AI gateway and pass it to scan_output.&lt;/p>
&lt;h2 id="when-to-choose-llm-guard-and-when-not-to">When to choose LLM Guard (and when not to)&lt;/h2>
&lt;p>&lt;strong>Choose LLM Guard when&lt;/strong>:&lt;/p>
&lt;ul>
&lt;li>The requirement for &lt;strong>PII anonymisation with automatic restitution&lt;/strong> is on the list. It is the number one reason to use it. Banking, healthcare, legal advice, HR — any case with heavy PII that must not reach the LLM even when it is local.&lt;/li>
&lt;li>You want a &lt;strong>Pythonic pipeline&lt;/strong> with no new DSL. If the team is pure Python and prefers composing scanners as objects to learning Colang.&lt;/li>
&lt;li>The stack already has an &lt;strong>AI gateway&lt;/strong> (LiteLLM, Envoy AI, Kong AI) and it integrates as a plugin without touching the app.&lt;/li>
&lt;li>You need &lt;strong>built-in OTel and Prometheus&lt;/strong> with no additional instrumentation.&lt;/li>
&lt;/ul>
&lt;p>&lt;strong>Do not choose LLM Guard when&lt;/strong>:&lt;/p>
&lt;ul>
&lt;li>The system is a &lt;strong>conversational agent with complex dialogue flows&lt;/strong> (policies, fallbacks, escalation to a human). There NeMo Guardrails with Colang is structurally better.&lt;/li>
&lt;li>The safety layer reduces to &lt;strong>validating structured outputs&lt;/strong> (JSON, function calling). Guardrails AI with its validators is more natural.&lt;/li>
&lt;li>Your &lt;strong>latency budget is ultra-aggressive&lt;/strong> (&amp;lt; 30 ms for the whole layer). You will have to cut scanners and accept lower coverage; perhaps a single PromptGuard 2 + Presidio in a sidecar (the pattern from the &lt;a href="https://blog.lo0.es/en/posts/llm-guardrails-safety-four-lines-of-defence/">guardrails post&lt;/a>) is simpler.&lt;/li>
&lt;li>You do not want to carry &lt;strong>the operational weight of a distributed Vault&lt;/strong> (Redis, TTL, sticky session). For small systems with no strong PII requirement, it is over-engineered.&lt;/li>
&lt;/ul>
&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>Custom scanners&lt;/strong>: how to write your own scanner when nothing in the catalogue fits (a complex domain regex, your own fine-tuned classifier). The project supports custom scanners by inheriting from &lt;code>InputScanner&lt;/code> / &lt;code>OutputScanner&lt;/code> with three methods.&lt;/li>
&lt;li>&lt;strong>Integration with SLSA / supply chain&lt;/strong>: how to sign the LLM Guard container with cosign, SLSA attestations, and verification in the cluster before admitting it. An operational &lt;a href="https://blog.lo0.es/en/posts/llm-guardrails-safety-four-lines-of-defence/">supply chain security&lt;/a> topic (OWASP LLM03).&lt;/li>
&lt;li>&lt;strong>Red teaming against LLM Guard&lt;/strong>: known techniques that evade detectors (homoglyphs, Unicode confusables, base64 encoding inside the prompt). The project publishes an adversarial test suite for doing your own benchmarking. How it is set up as a continuous gate in CI.&lt;/li>
&lt;li>&lt;strong>Comparative benchmark against Bedrock Guardrails and Azure AI Content Safety&lt;/strong>: F1 per category over real traffic across three different deployments. The &lt;a href="https://blog.lo0.es/en/posts/parallel-catalogue-llmops-stages-open-source-hyperscalers/">OSS vs hyperscalers post&lt;/a> has the strategic comparison; the technical detection comparison is still missing.&lt;/li>
&lt;/ul>
&lt;h2 id="references">References&lt;/h2>
&lt;ul>
&lt;li>&lt;strong>LLM Guard (Protect AI)&lt;/strong>: &lt;a href="https://llm-guard.com">https://llm-guard.com&lt;/a> — official documentation, scanner list, benchmarks.&lt;/li>
&lt;li>&lt;strong>Repository&lt;/strong>: &lt;a href="https://github.com/protectai/llm-guard">https://github.com/protectai/llm-guard&lt;/a>.&lt;/li>
&lt;li>&lt;strong>LLM Guard API&lt;/strong>: &lt;a href="https://github.com/protectai/llm-guard/tree/main/llm_guard_api">https://github.com/protectai/llm-guard/tree/main/llm_guard_api&lt;/a>.&lt;/li>
&lt;li>&lt;strong>Presidio (Microsoft)&lt;/strong>: &lt;a href="https://microsoft.github.io/presidio/">https://microsoft.github.io/presidio/&lt;/a> — basis of the Anonymize scanner.&lt;/li>
&lt;li>&lt;strong>detect-secrets (Yelp)&lt;/strong>: &lt;a href="https://github.com/Yelp/detect-secrets">https://github.com/Yelp/detect-secrets&lt;/a> — basis of the Secrets scanner.&lt;/li>
&lt;li>&lt;strong>Langfuse OTel ingestion&lt;/strong>: &lt;a href="https://langfuse.com/docs/opentelemetry/get-started">https://langfuse.com/docs/opentelemetry/get-started&lt;/a>.&lt;/li>
&lt;li>&lt;strong>LiteLLM guardrails&lt;/strong>: &lt;a href="https://docs.litellm.ai/docs/proxy/guardrails">https://docs.litellm.ai/docs/proxy/guardrails&lt;/a>.&lt;/li>
&lt;li>&lt;strong>Envoy AI Gateway&lt;/strong>: &lt;a href="https://aigateway.envoyproxy.io">https://aigateway.envoyproxy.io&lt;/a>.&lt;/li>
&lt;li>&lt;strong>Kong AI Gateway&lt;/strong>: &lt;a href="https://docs.konghq.com/hub/kong-inc/ai-prompt-guard/">https://docs.konghq.com/hub/kong-inc/ai-prompt-guard/&lt;/a>.&lt;/li>
&lt;li>&lt;strong>OWASP Top 10 for LLM Applications 2025&lt;/strong>: &lt;a href="https://owasp.org/www-project-top-10-for-large-language-model-applications/">https://owasp.org/www-project-top-10-for-large-language-model-applications/&lt;/a>.&lt;/li>
&lt;li>&lt;strong>ONNX Runtime&lt;/strong>: &lt;a href="https://onnxruntime.ai">https://onnxruntime.ai&lt;/a> — exporting HF models to ONNX for acceleration.&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/hardening-secrets-sovereign-llm-stack-defence-in-depth/">Hardening and secrets of the sovereign LLM stack: defence in depth&lt;/a> — guardrails as one more layer of the stack&amp;rsquo;s defence in depth.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/llm-guardrails-safety-four-lines-of-defence/">Guardrails and safety in LLMs: the four lines of defence&lt;/a> — the framework that places LLM Guard as one of the tools within the layer. That post explains the four lines (input, retrieval, tool, output), the OWASP LLM Top 10 and compares NeMo Guardrails, Llama Guard 4, ShieldGemma, Granite Guardian, PromptGuard 2 and LLM Guard from a bird&amp;rsquo;s-eye view.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&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> — an extended write-up of LLM Guard among the rest of the OSS tools by pipeline stage.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/rag-corpus-curation-active-librarian/">RAG corpus curation: the active librarian&lt;/a> — prevention at ingest shares the Presidio PII detector with LLM Guard; the Vault pattern is the new piece added at runtime.&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 OTel plane over which LLM Guard emits the &lt;code>gen_ai.guardrail.*&lt;/code> spans that Langfuse and Tempo consume.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&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> travels as a span attribute even when the prompt content is anonymised; it complements this post&amp;rsquo;s PII shielding.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/evals-llm-layer-after-tracing/">Evals for LLMs: the layer after tracing&lt;/a> — LLM Guard&amp;rsquo;s offline counterpart. When a scanner reports a high FP rate over real traffic, the offline exercise against an annotated golden set identifies whether to tune the threshold or change the backend model.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/retrain-closing-the-loop-feedback-dataset-adapter/">Retrain: closing the feedback → dataset → adapter loop&lt;/a> — the HIGH severity incidents that LLM Guard emits with &lt;code>risk_score &amp;gt; threshold&lt;/code> feed the incident-driven retrain loop.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/parallel-catalogue-llmops-stages-open-source-hyperscalers/">OSS vs hyperscalers in LLMOps&lt;/a> — the OSS column of the &amp;ldquo;Guardrails&amp;rdquo; row (NeMo + Presidio + Llama Guard 4 + &lt;strong>LLM Guard&lt;/strong>) against Bedrock Guardrails, Azure AI Content Safety and Vertex Model Armor.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/structured-output-bitmask-outlines-xgrammar-llguidance/">Structured output: function calling and constrained decoding&lt;/a> — LLM Guard&amp;rsquo;s JSON scanner validates the output structure as a safety net when the inference engine has already done constrained decoding.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&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 Guardrails (this post included) is the online counterpart of the Eval stage.&lt;/p>
&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>Evals for LLMs: the layer after tracing that decides whether your model performs or only looks like it does</title><link>https://blog.lo0.es/en/posts/evals-llm-layer-after-tracing/</link><pubDate>Mon, 25 May 2026 07:30:00 +0200</pubDate><guid>https://blog.lo0.es/en/posts/evals-llm-layer-after-tracing/</guid><description>&lt;blockquote>
&lt;p>This is &lt;strong>stage 3&lt;/strong> of the LLMOps pipeline. If you arrive without context on the full journey, the &lt;a href="https://blog.lo0.es/en/posts/six-stage-llmops-pipeline-architecture-and-deep-dive/">six-stage LLMOps pipeline&lt;/a> describes where Eval sits between Tune and Deploy, and the &lt;a href="https://blog.lo0.es/en/posts/anatomy-llm-request-production-tour-six-stages/">anatomy of a production LLM request&lt;/a> shows a real eval blocking the promotion of an adapter.&lt;/p>
&lt;/blockquote>
&lt;h2 id="tldr">TL;DR&lt;/h2>
&lt;p>Tracing is not evaluation. &lt;strong>Tracing tells you what happened; eval tells you whether what happened is any good.&lt;/strong> The two layers live in tools that overlap visually (Langfuse does both), but the discipline is different: tracing is continuous capture over real traffic; eval is controlled execution against a stable dataset, with metrics that have to &lt;strong>fail CI&lt;/strong> if they drop below a threshold. Without eval, the &lt;code>Tune → Deploy&lt;/code> loop closes blind: adapter v8 goes to production because the engineer &amp;ldquo;saw it answering well&amp;rdquo; on five examples. With eval done properly, v8 only gets in if it beats v7 across a battery of 500 curated cases, evaluated by a mix of heuristics, embeddings, an LLM judge calibrated against humans, and a sample of real traffic with human annotation. This post takes apart the mechanism, the mathematics that keep you honest, the real tools in 2026 and the pitfalls that turn it into theatre.&lt;/p>
&lt;h2 id="the-analogy-the-academic-examination-board">The analogy: the academic examination board&lt;/h2>
&lt;div class="diagram" style="max-width:760px;margin:1.5rem auto;">
&lt;svg viewBox="0 0 760 320" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Eval as an academic examination board">
&lt;style>
.tbox{fill:#f8f8f8;stroke:#444;stroke-width:1.4;rx:8}
.thead{fill:#7aafff;stroke:#444;stroke-width:1.4;rx:8}
.tlbl{font:600 13px sans-serif;fill:#222}
.tsub{font:400 11px sans-serif;fill:#555}
.tarr{stroke:#666;stroke-width:1.5;fill:none;marker-end:url(#me1)}
.tgate{fill:#ffd76b;stroke:#444;stroke-width:1.6;rx:6}
&lt;/style>
&lt;defs>&lt;marker id="me1" 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;rect x="20" y="20" width="160" height="60" class="thead"/>
&lt;text x="100" y="44" text-anchor="middle" class="tlbl">Candidate&lt;/text>
&lt;text x="100" y="62" text-anchor="middle" class="tsub">model + adapter + prompt&lt;/text>
&lt;text x="100" y="76" text-anchor="middle" class="tsub">(the "thesis" being defended)&lt;/text>
&lt;rect x="240" y="20" width="200" height="60" class="tbox"/>
&lt;text x="340" y="44" text-anchor="middle" class="tlbl">Fixed syllabus&lt;/text>
&lt;text x="340" y="62" text-anchor="middle" class="tsub">versioned golden dataset&lt;/text>
&lt;text x="340" y="76" text-anchor="middle" class="tsub">(500 questions with expected answer)&lt;/text>
&lt;rect x="500" y="20" width="240" height="60" class="tbox"/>
&lt;text x="620" y="44" text-anchor="middle" class="tlbl">Mixed board&lt;/text>
&lt;text x="620" y="62" text-anchor="middle" class="tsub">heuristics + embeddings + LLM judge&lt;/text>
&lt;text x="620" y="76" text-anchor="middle" class="tsub">+ human sample (calibrated panel)&lt;/text>
&lt;path class="tarr" d="M180,50 L240,50"/>
&lt;path class="tarr" d="M440,50 L500,50"/>
&lt;rect x="40" y="130" width="180" height="60" class="tbox"/>
&lt;text x="130" y="154" text-anchor="middle" class="tlbl">Marks per category&lt;/text>
&lt;text x="130" y="172" text-anchor="middle" class="tsub">faithfulness, relevancy,&lt;/text>
&lt;text x="130" y="186" text-anchor="middle" class="tsub">format, toxicity, latency&lt;/text>
&lt;rect x="270" y="130" width="220" height="60" class="tbox"/>
&lt;text x="380" y="154" text-anchor="middle" class="tlbl">Aggregation + segmentation&lt;/text>
&lt;text x="380" y="172" text-anchor="middle" class="tsub">global mean, by language,&lt;/text>
&lt;text x="380" y="186" text-anchor="middle" class="tsub">by tenant, by question type&lt;/text>
&lt;rect x="540" y="130" width="200" height="60" class="tgate"/>
&lt;text x="640" y="154" text-anchor="middle" class="tlbl">Eval gate (pass mark)&lt;/text>
&lt;text x="640" y="172" text-anchor="middle" class="tsub">faithfulness ≥ 0.85 ∧ tox &amp;lt; 0.02&lt;/text>
&lt;text x="640" y="186" text-anchor="middle" class="tsub">∧ regression vs baseline &amp;lt; 2 pp&lt;/text>
&lt;path class="tarr" d="M620,86 L130,124"/>
&lt;path class="tarr" d="M620,86 L380,124"/>
&lt;path class="tarr" d="M620,86 L640,124"/>
&lt;rect x="40" y="240" width="320" height="60" class="tbox"/>
&lt;text x="200" y="264" text-anchor="middle" class="tlbl">Pass → promotion to production&lt;/text>
&lt;text x="200" y="282" text-anchor="middle" class="tsub">label `production` moves to the candidate&lt;/text>
&lt;rect x="400" y="240" width="320" height="60" class="tbox"/>
&lt;text x="560" y="264" text-anchor="middle" class="tlbl">Fail → back to Tune&lt;/text>
&lt;text x="560" y="282" text-anchor="middle" class="tsub">incident or regression stays in lineage&lt;/text>
&lt;path class="tarr" d="M580,196 L200,236"/>
&lt;path class="tarr" d="M700,196 L560,236"/>
&lt;/svg>
&lt;/div>
&lt;p>A doctoral candidate defends a thesis. They do not defend it before a single distracted professor: they defend it before a &lt;strong>mixed board&lt;/strong>, with a &lt;strong>syllabus fixed in advance&lt;/strong> (not improvised on the day), and with an &lt;strong>explicit pass mark&lt;/strong> that separates pass from fail. The board is not a single expert: it is a panel combining fast readers (the heuristics: does it have the requested format? does it include the citation?), semantic reviewers (the embeddings: does it resemble the expected answer?), an external assessor trained on the subject (the LLM judge: is it faithful to the context, is it relevant, does it sound coherent?), and human jury members on a &lt;strong>sample of the most sensitive cases&lt;/strong>. If the candidate does not reach the mark, they are not promoted: they go back and prepare the defence again.&lt;/p>
&lt;p>That analogy has three edges worth holding on to from the first minute:&lt;/p>
&lt;ol>
&lt;li>&lt;strong>The syllabus is fixed&lt;/strong>, but &lt;strong>it is actively updated when a new topic appears in the real world&lt;/strong> that the candidate must know. If it is not updated, the exam measures something increasingly distant from what happens outside.&lt;/li>
&lt;li>&lt;strong>The board has to be trained&lt;/strong>: an LLM judge not calibrated against humans is an examiner making up the criteria.&lt;/li>
&lt;li>&lt;strong>The pass mark is published beforehand&lt;/strong>: it is not decided after seeing the result, because then it is not a pass mark, it is a justification.&lt;/li>
&lt;/ol>
&lt;p>These three ideas run through the rest of the post. Every tool and every piece of mathematics that follows is, at bottom, a way of operationalising them.&lt;/p>
&lt;h2 id="the-mechanism-itself-four-layers-of-evaluators">The mechanism itself: four layers of evaluators&lt;/h2>
&lt;p>An eval suite in 2026 is made up of four layers that coexist. None replaces the others; each one measures what the rest cannot measure well, and leaves unmeasured what the rest do measure well.&lt;/p>
&lt;p>&lt;strong>Layer 1 — Deterministic heuristics.&lt;/strong> Rules that return &lt;code>true&lt;/code> or &lt;code>false&lt;/code> with no ambiguity: the output matches a regex, contains a specific entity, does not exceed a length, follows a valid JSON schema, respects a requested format (markdown, &lt;code>tool_call&lt;/code> function, mandatory citation). They are cheap, extremely fast, need neither judge nor embeddings, and catch the most frequent class of bug: the model answered in the wrong format. Their limit is obvious, in that they do not know whether the answer is &lt;strong>correct&lt;/strong>, only whether it is &lt;strong>well formed&lt;/strong>.&lt;/p>
&lt;p>&lt;strong>Layer 2 — Semantic metrics with embeddings.&lt;/strong> They compare the model&amp;rsquo;s output with an expected answer by computing cosine similarity between their embeddings, or by measuring whether a claim in the output is entailed by the retrieved context. They are cheap, deterministic given the embedding model, and very useful for detecting answers that drift in meaning but not in form. Their limit is equally clear: two answers can have high cosine similarity and say the opposite of each other (&amp;ldquo;the customer can cancel at any time&amp;rdquo; vs &amp;ldquo;the customer cannot cancel at any time&amp;rdquo; share 80% of their tokens).&lt;/p>
&lt;p>&lt;strong>Layer 3 — LLM-as-judge.&lt;/strong> A model, or an ensemble, evaluates the output of the model under test with a prompt designed to produce a score on a rubric. The canonical methods in 2026 are &lt;strong>G-Eval&lt;/strong> (chain-of-thought prompting with a calibrated numeric score), &lt;strong>Prometheus&lt;/strong> (open-source judge trained specifically for evals, reporting 0.897 correlation with humans in its v2.5 release from late 2025), and &lt;strong>panel-of-judges&lt;/strong> setups that average votes from three heterogeneous models to reduce bias. This layer captures nuances the previous two cannot see: is it faithful to the context? is it useful? is it safe? is it complete? Its limit is cost and the need for calibration, treated as a whole section below.&lt;/p>
&lt;p>&lt;strong>Layer 4 — Humans.&lt;/strong> Trained annotators who evaluate a &lt;strong>sample&lt;/strong> of the eval set, not all of it. They are the only layer with ultimate authority over the rubric: the LLM judge is calibrated against them, not the other way round. They are expensive (≈ 0.50–2.00 € per annotated sample when the domain is technical) and slow (a competent annotator produces 60–120 quality annotations per working day). The mistake teams make again and again is dropping this layer &amp;ldquo;because we have an LLM judge&amp;rdquo;; without humans there is no calibration, and without calibration the judge measures whatever it feels like.&lt;/p>
&lt;p>Normal operation of an eval gate combines all four: heuristics remove malformed outputs before spending judge tokens, embeddings filter out the manifestly irrelevant, the judge scores the rest, and humans annotate a sample every N runs to keep the judge calibrated.&lt;/p>
&lt;h2 id="the-golden-dataset-a-versioned-syllabus">The golden dataset: a versioned syllabus&lt;/h2>
&lt;p>The golden dataset is the most undervalued artefact in the pipeline. It is &lt;strong>the exam&lt;/strong>. If it is badly built, everything else, the best-calibrated judge in the world, the strictest gates, the fastest suite, measures noise. Covered at the top level in the &lt;a href="https://blog.lo0.es/en/posts/data-versioning-llmops-dvc-lakefs-reproducible-golden-dataset/">data versioning post&lt;/a> as one of the four artefacts to version differently; here we go into detail from the Eval perspective.&lt;/p>
&lt;p>The three properties a golden dataset has to satisfy are:&lt;/p>
&lt;p>&lt;strong>Stratified representativeness.&lt;/strong> The dataset has to cover the system&amp;rsquo;s real input space in proportions that reflect production. If 30% of real traffic is in German, 30% of the golden set has to be in German; if 12% of questions are about subscription cancellation, that category cannot be 60% of the eval set just because it was easy to annotate. Stratification stays auditable: each example carries tags (&lt;code>lang=de&lt;/code>, &lt;code>category=cancellation&lt;/code>, &lt;code>tenant_type=enterprise&lt;/code>, &lt;code>difficulty=hard&lt;/code>) and the suite reports metrics per segment, not just the aggregate.&lt;/p>
&lt;p>&lt;strong>Strict holdout, no contamination with training.&lt;/strong> This rule is so obvious that almost every team believes it is following it, and almost every team breaks it without noticing. If the golden eval set gets mixed with the fine-tuning dataset, because somebody did a badly built &lt;code>random_split&lt;/code>, because a purchased dataset is used by other vendors for training, because the LLM judge saw it during pretraining, then the metric stops measuring generalisation and starts measuring memorisation. The eval set hash is versioned separately (covered in the &lt;a href="https://blog.lo0.es/en/posts/continuous-fine-tuning-production-real-traffic-deployed-adapter/">continuous fine-tuning post&lt;/a>) and a routine leakage check is run: if an example in the golden set matches one in training token for token, it raises an alert.&lt;/p>
&lt;p>&lt;strong>Reasoned sample size, not aspirational.&lt;/strong> How many examples are needed? There is a mathematical minimum for telling two models apart with confidence. If model A is right 80% of the time and model B 85% on the same set, the 95% confidence interval for that difference of proportions (unpaired) is:&lt;/p>
\[
\Delta p \pm 1{.}96 \cdot \sqrt{\frac{p_A(1-p_A) + p_B(1-p_B)}{n}}
\]
&lt;p>To distinguish 80% from 85% with 95% confidence (an interval that does not cross zero), you need n ≈ 700 examples. To distinguish 90% from 91% under the same criterion, the calculation gives n ≈ 6,500. A &amp;ldquo;we have 50 examples in the golden set and we see adapter v8 scoring 90%&amp;rdquo; means nothing statistically: the confidence interval is ±8 points. The field&amp;rsquo;s practical rule in 2026 is &lt;strong>a minimum of 300 examples to detect coarse differences, ideally 500–1,500 if you want to detect fine improvements&lt;/strong>, and to start by stratifying properly before obsessing over sample size.&lt;/p>
&lt;p>On top of this comes &lt;strong>active maintenance&lt;/strong>: the golden set is enriched with &lt;strong>production incidents&lt;/strong> (covered in detail in the &lt;a href="https://blog.lo0.es/en/posts/retrain-closing-the-loop-feedback-dataset-adapter/">retrain post&lt;/a>), so that every real complaint that ends in a bug becomes a curated example the next deploy candidate will have to pass. The golden set is not static: it is a &lt;strong>living record of errors the system has already made and must not make again&lt;/strong>.&lt;/p>
&lt;h2 id="llm-as-judge-how-you-calibrate-an-examiner">LLM-as-judge: how you calibrate an examiner&lt;/h2>
&lt;p>Layer 3 is the one most teams misuse. The typical mistake is: &amp;ldquo;we use GPT-4 as judge because it is the most capable&amp;rdquo;. The judge is not chosen for nominal capability; it is chosen for &lt;strong>agreement with humans&lt;/strong> on the specific rubric you are measuring. A judge with 60% agreement is useless even if it is GPT-5; a Prometheus 7B fine-tuned for your domain with 88% agreement is worth more.&lt;/p>
&lt;p>The standard metric for measuring agreement between two annotators (human vs judge, or two humans with each other) is &lt;strong>Cohen&amp;rsquo;s kappa&lt;/strong>, which corrects for the agreement expected by chance:&lt;/p>
\[
\kappa = \frac{p_o - p_e}{1 - p_e}
\]
&lt;p>where \(p_o\) is the observed proportion of agreement and \(p_e\) is the proportion expected by chance under each annotator&amp;rsquo;s marginal distributions. The accepted interpretations in the literature are:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>κ &amp;lt; 0.40&lt;/strong>: poor agreement. The judge says whatever it likes.&lt;/li>
&lt;li>&lt;strong>κ ∈ [0.40, 0.60]&lt;/strong>: moderate. Acceptable for coarse signals (is it toxic?), dreadful for nuance (is it faithful to the context?).&lt;/li>
&lt;li>&lt;strong>κ ∈ [0.60, 0.80]&lt;/strong>: substantial. Useful in production for most metrics.&lt;/li>
&lt;li>&lt;strong>κ &amp;gt; 0.80&lt;/strong>: almost perfect. The judge can be treated as a substitute for the human for that specific kind of judgement.&lt;/li>
&lt;/ul>
&lt;p>Numerically, consider a binary rubric (faithful / not faithful) over 200 examples annotated by a human and by a judge. If the human said &amp;ldquo;faithful&amp;rdquo; in 150 cases and the judge in 140, and they agree on 175 of the 200, then \(p_o = 0{.}875\); the marginals are \(p_h = 0{.}75\), \(p_j = 0{.}70\), and \(p_e = 0{.}75 \cdot 0{.}70 + 0{.}25 \cdot 0{.}30 = 0{.}600\). Kappa comes out at &lt;strong>0.6875&lt;/strong>: substantial but not excellent, usable, with an eye kept on the rubric.&lt;/p>
&lt;p>Calibrating the judge means an explicit process:&lt;/p>
&lt;ol>
&lt;li>&lt;strong>Build a calibration set&lt;/strong> — 100–300 examples annotated by trained humans, with written guidelines. Inter-annotator kappa between the humans is also measured; if the humans themselves cannot agree, the rubric is badly written before you even start talking about a judge.&lt;/li>
&lt;li>&lt;strong>Iterate the judge prompt&lt;/strong> until judge agreement with the humans passes the accepted threshold (typically κ ≥ 0.7 for sensitive metrics).&lt;/li>
&lt;li>&lt;strong>Pin the judge version&lt;/strong> (&lt;code>claude-3-5-sonnet-20251022&lt;/code>, &lt;code>gpt-4o-2024-11&lt;/code>, &lt;code>prometheus-2-7b@sha256:…&lt;/code>): any change invalidates the calibration.&lt;/li>
&lt;li>&lt;strong>Recalibrate periodically&lt;/strong> — every time the judge, the judge prompt, or the rubric changes. The frequency the field recommends in 2026 is quarterly at minimum, monthly if the rubric is new.&lt;/li>
&lt;li>&lt;strong>Persist everything in lineage&lt;/strong> — a score of &amp;ldquo;faithfulness 0.87&amp;rdquo; with no traceability of which judge, which prompt, which calibration set and which human validated it, is decorative.&lt;/li>
&lt;/ol>
&lt;p>The &lt;a href="https://blog.lo0.es/en/posts/prompt-versioning-contract-five-word-change/">prompt versioning post&lt;/a> covers how judge prompt versioning is materialised. The practical consequence is that &lt;strong>the judge is versioned like any other model&lt;/strong>: your eval suite has &lt;code>judge_id = prometheus-2.5@v3&lt;/code> just as it has &lt;code>adapter_id = customer_support_v7&lt;/code>.&lt;/p>
&lt;h2 id="the-two-cadences-ci-gate-and-platform-regression">The two cadences: CI gate and platform regression&lt;/h2>
&lt;p>Eval suites live in two places and run on two different cadences. Teams that conflate them turn one of the two into theatre.&lt;/p>
&lt;p>&lt;strong>CI gate (pre-merge, blocking).&lt;/strong> It runs on every pull request that modifies prompts, adapters, RAG configuration, or any artefact that could move the model&amp;rsquo;s output. It runs against the golden dataset versioned at the hash that is on &lt;code>main&lt;/code>. The gate fails the merge if:&lt;/p>
&lt;ul>
&lt;li>the critical metric drops &lt;strong>more than X percentage points&lt;/strong> in absolute terms against the baseline (typically X = 2);&lt;/li>
&lt;li>some safety metric (toxicity, PII leakage) crosses a hard threshold (typically tox &amp;gt; 0.02);&lt;/li>
&lt;li>some strategic segment (a language, an enterprise-type tenant) drops more than Y points even if the aggregate improves.&lt;/li>
&lt;/ul>
&lt;p>This cadence has to be &lt;strong>fast&lt;/strong> (ideally &amp;lt; 10 minutes over 500 examples) and &lt;strong>cheap&lt;/strong> (LLM judge in batch mode, cached embeddings, heuristics run locally). The CI gate is not exhaustive: it is the low-latency line of defence.&lt;/p>
&lt;p>&lt;strong>Platform regression (post-deploy, continuous).&lt;/strong> It runs on a schedule (typically nightly or weekly) over &lt;strong>sampled production traffic&lt;/strong>, not over the static golden set. It detects drift: the model has not changed, the golden set has not changed, but the users have, and quality over real traffic drops. This cadence is more expensive (judge over thousands of samples, human annotation over hundreds), tolerates latencies of hours, and its main consumer is not CI but the &lt;a href="https://blog.lo0.es/en/posts/agentsight-llm-tracing-ebpf-zero-instrumentation/">observability&lt;/a> dashboard and the humans on the product team who decide whether to open a &lt;a href="https://blog.lo0.es/en/posts/retrain-closing-the-loop-feedback-dataset-adapter/">retrain&lt;/a> cycle.&lt;/p>
&lt;p>Both cadences persist results in the same store (Langfuse, MLflow, or equivalent) and connect them by &lt;code>model_id&lt;/code>, &lt;code>prompt_id&lt;/code>, &lt;code>dataset_hash&lt;/code> and &lt;code>judge_id&lt;/code>. Without that glue of identifiers, the metric that passes CI cannot be correlated with the one that fails in production three weeks later.&lt;/p>
&lt;h2 id="the-minimum-mathematics-that-matter">The minimum mathematics that matter&lt;/h2>
&lt;p>Beyond kappa and the interval for the difference of proportions, both already covered above, there are three more mathematical pieces that any team running evals seriously ends up using.&lt;/p>
&lt;p>&lt;strong>Confidence interval for a continuous metric.&lt;/strong> If your metric is a continuous score (faithfulness ∈ [0, 1]) and you measure the sample mean \(\bar{x}\) over n examples with standard deviation s, the 95% confidence interval for the population mean is:&lt;/p>
\[
\bar{x} \pm 1{.}96 \cdot \frac{s}{\sqrt{n}}
\]
&lt;p>For n = 300 and s ≈ 0.2 (typical of a 0-1 score with non-degenerate variance), the margin is ±0.023. This means that differences below 2 hundredths &lt;strong>cannot be distinguished&lt;/strong> from noise at that sample size. If your team is chasing &amp;ldquo;+0.5 pp&amp;rdquo; improvements over 100 examples, it is optimising noise.&lt;/p>
&lt;p>&lt;strong>Judge cost as a function of sample size and rubric.&lt;/strong> The cost of one eval pass with LLM-as-judge over n examples, with m metrics evaluated in a single call per example, and per-token price \(c_{in}, c_{out}\) on the judge model, is:&lt;/p>
\[
C \approx n \cdot (t_{in} \cdot c_{in} + t_{out} \cdot c_{out})
\]
&lt;p>where \(t_{in}\) is the number of input tokens (includes context, output of the model under test, full rubric) and \(t_{out}\) the output tokens (includes the judge&amp;rsquo;s CoT + score). For n = 500, \(t_{in}\) ≈ 4,000, \(t_{out}\) ≈ 300, a GPT-4o judge at May 2026 prices, one pass costs on the order of &lt;strong>8–15 USD per suite&lt;/strong>. If the pass fires on every PR and there are 30 PRs/day, that is 240–450 USD/day on CI gates alone. Multiplied by continuous regression, teams that do not control this spend four figures a month on judge tokens without realising. The canonical mitigation is a &lt;strong>mix of layers&lt;/strong>: heuristics and embeddings filter first, the judge is only invoked on what the cheap layers cannot resolve, and for platform regression a self-hosted open-source judge is used (Prometheus 7B on your own GPU plane) instead of a commercial model.&lt;/p>
&lt;p>&lt;strong>Distinction between aggregate metric and per-segment metric.&lt;/strong> The classic eval fallacy is the hidden mean. If your suite reports &lt;code>faithfulness = 0.87&lt;/code> and the team reads it as &amp;ldquo;up 2 points against the previous adapter&amp;rdquo;, this may be what is happening: the new adapter gains 4 points in English (where 70% of the eval set is) and loses 6 points in German (where 30% is). The aggregate mean improves, the German experience gets worse. Any serious suite reports a &lt;strong>breakdown by strategic segment&lt;/strong> (language, tenant type, question category, context length). The CI gate can also have per-segment thresholds, not just aggregate ones.&lt;/p>
&lt;h2 id="the-2026-stack-dominant-tools">The 2026 stack: dominant tools&lt;/h2>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Tool&lt;/th>
&lt;th>Main layer&lt;/th>
&lt;th>Licence&lt;/th>
&lt;th>Maintainer&lt;/th>
&lt;th>When to pick it&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>&lt;strong>DeepEval&lt;/strong>&lt;/td>
&lt;td>CI gate&lt;/td>
&lt;td>Apache 2.0&lt;/td>
&lt;td>Confident AI&lt;/td>
&lt;td>&amp;ldquo;Evals as pytest&amp;rdquo; — assertions in Python code, trivial integration with GitHub Actions. A reasonable default.&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Promptfoo&lt;/strong>&lt;/td>
&lt;td>CI gate&lt;/td>
&lt;td>MIT&lt;/td>
&lt;td>Promptfoo Inc.&lt;/td>
&lt;td>Declarative YAML, matrix of prompts × providers × assertions, diff vs baseline. DevOps-friendly.&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>RAGAS&lt;/strong>&lt;/td>
&lt;td>RAG-specific metrics&lt;/td>
&lt;td>Apache 2.0&lt;/td>
&lt;td>Exploding Gradients&lt;/td>
&lt;td>Faithfulness, context relevancy, answer relevancy. The canonical piece if your system is RAG.&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Inspect AI&lt;/strong>&lt;/td>
&lt;td>Safety/capability evals&lt;/td>
&lt;td>MIT&lt;/td>
&lt;td>UK AI Safety Institute&lt;/td>
&lt;td>Suite focused on safety and capability. Useful for regulatory gates under the EU AI Act.&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Langfuse Evals&lt;/strong>&lt;/td>
&lt;td>Platform regression&lt;/td>
&lt;td>MIT (OSS) / EE&lt;/td>
&lt;td>Langfuse GmbH&lt;/td>
&lt;td>Integrated with tracing — datasets, runs and scores in the same UI as production traces.&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>MLflow GenAI Evals&lt;/strong>&lt;/td>
&lt;td>Registry + evals&lt;/td>
&lt;td>Apache 2.0&lt;/td>
&lt;td>Databricks/LF AI&lt;/td>
&lt;td>Good when you already have MLflow for classic models; &amp;ldquo;GenAI dashboard&amp;rdquo; since 3.10.&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Phoenix (Arize)&lt;/strong>&lt;/td>
&lt;td>Eval + visual drift&lt;/td>
&lt;td>Elastic License 2.0&lt;/td>
&lt;td>Arize AI&lt;/td>
&lt;td>Focused on visual debugging of embeddings and drift; a complement to Langfuse, not a replacement.&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Prometheus 2.5&lt;/strong>&lt;/td>
&lt;td>Self-hosted OSS judge&lt;/td>
&lt;td>Apache 2.0&lt;/td>
&lt;td>KAIST + LG AI&lt;/td>
&lt;td>Fine-tuned judge, high correlation with GPT-4 at zero cost per token when self-hosted.&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>G-Eval / JudgeLM&lt;/strong>&lt;/td>
&lt;td>Prompting methods&lt;/td>
&lt;td>— (techniques)&lt;/td>
&lt;td>academic&lt;/td>
&lt;td>Prompting frameworks for LLM-as-judge — they apply on top of any judge model.&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>The &lt;a href="https://blog.lo0.es/en/posts/oss-catalogue-llmops-six-stages-tool-by-tool/">OSS catalogue by stage&lt;/a> goes into detail entry by entry; the &lt;a href="https://blog.lo0.es/en/posts/parallel-catalogue-llmops-stages-open-source-hyperscalers/">parallel OSS vs hyperscalers catalogue&lt;/a> compares against Bedrock Evaluations, Vertex AI Eval Service and Azure AI Evaluation.&lt;/p>
&lt;p>The canonical pattern in 2026 is hybrid: &lt;strong>DeepEval or Promptfoo for CI gates + Langfuse Evals for platform regression + Prometheus 2.5 as a self-hosted judge + human annotation on Argilla or Label Studio for the calibration set&lt;/strong>. Swapping any of these pillars for equivalents (W&amp;amp;B Weave instead of Langfuse, Inspect instead of DeepEval) is style, not functionality; what matters is that the four functions are present and connected.&lt;/p>
&lt;h2 id="applied-to-typical-on-premise-hardware">Applied to typical on-premise hardware&lt;/h2>
&lt;p>For an on-premise deployment that wants to avoid sending sensitive outputs to a commercial LLM judge (for data sovereignty, ENS, NIS2 or equivalents), the judge is hosted on your own GPU plane. Reference figures for May 2026, based on &lt;strong>Prometheus 2.5 (Llama-3.1-8B fine-tuned as a judge)&lt;/strong> served on vLLM:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>RTX 4090 (24 GB, Ada Lovelace)&lt;/strong>: viable for small suites (&amp;lt; 200 examples) and for running the judge offline during development. Latency per evaluation ≈ 1.5–3 s with BF16; aggregate throughput on the order of 25–40 evaluations/min with batching. Useful for a developer&amp;rsquo;s local CI gates, not for platform regression.&lt;/li>
&lt;li>&lt;strong>Generic 4×H100 SXM configuration (320 GB total, NVLink)&lt;/strong>: runs the judge in parallel at TP=2 across two GPUs, leaving two free to serve the model under test. Aggregate throughput on the order of 200–350 evaluations/min, a full 500-example suite in 2–3 min. This allows PR gates with no perceptible waiting and nightly platform regression over thousands of samples with no per-token cost.&lt;/li>
&lt;/ul>
&lt;p>The comparative cost arithmetic is stubborn: self-hosting Prometheus 2.5 pays off an H100 in roughly 6 months &lt;strong>if the team fires ≥ 30 PRs/day with eval gates&lt;/strong>. Below that volume, the commercial judge still wins unless sovereignty is a requirement, and under ENS / NIS2 it is.&lt;/p>
&lt;h2 id="the-seven-pitfalls-that-kill-this-stage">The seven pitfalls that kill this stage&lt;/h2>
&lt;p>&lt;strong>Pitfall 1 — Aged golden dataset.&lt;/strong> It is not enriched with production incidents. After months it measures a world that no longer exists. The metric climbs comfortably while real users complain more.&lt;/p>
&lt;p>&lt;strong>Pitfall 2 — Contaminated or uncalibrated judge.&lt;/strong> The LLM judge evaluates with criteria it invents itself. Without a human calibration set as reference, there is no way to know whether its 0.89 is generous, severe or random.&lt;/p>
&lt;p>&lt;strong>Pitfall 3 — Insufficient sample size.&lt;/strong> A 50-example suite, 1-point differences the team treats as significant. The confidence interval is ±10 points. They are measuring noise and making real decisions on it.&lt;/p>
&lt;p>&lt;strong>Pitfall 4 — Runaway cost.&lt;/strong> A GPT-4 batch-mode judge fired on every PR over 1,000 examples, with no prior filtering by cheap layers. The eval bill overtakes production serving. It happens more often than people admit.&lt;/p>
&lt;p>&lt;strong>Pitfall 5 — Aggregate metric hiding segments.&lt;/strong> Global mean improves 2 points, German drops 6, enterprise tenants drop 3. With no explicit per-segment breakdown, the gate approves what it should not.&lt;/p>
&lt;p>&lt;strong>Pitfall 6 — Judge with a floating version.&lt;/strong> Judge model updated without recalibrating the rubric. Thresholds lose their statistical meaning. Last month&amp;rsquo;s regressions are not comparable with those from two months ago.&lt;/p>
&lt;p>&lt;strong>Pitfall 7 — Eval gate that is not enforced.&lt;/strong> The gate exists in the documentation but not in the real workflow: the suite takes 30 minutes, developers skip it with &lt;code>--no-verify&lt;/code>, managers ask for one-off exceptions that become the norm. An eval gate without enforcement is ornament.&lt;/p>
&lt;p>All seven are operational, not technical. The Eval layer does not break because the mathematics are wrong: it breaks because the discipline slackens. It is the same thing that happens with unit tests in any project that grows, except that here, without the discipline, the system &lt;strong>improves its metrics while getting worse&lt;/strong>, and that makes the degradation invisible until it is already ungovernable.&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>Guardrails and online safety&lt;/strong>: the &lt;strong>inline&lt;/strong> eval layer that filters outputs in real time, not in CI. Conceptually a cousin of Eval, but with very different latency constraints. Covered in detail in the &lt;a href="https://blog.lo0.es/en/posts/llm-guardrails-safety-four-lines-of-defence/">guardrails post&lt;/a>: four lines of defence (input, retrieval, tool, output), OWASP LLM Top 10 (2025), OSS catalogue and deployment patterns.&lt;/li>
&lt;li>&lt;strong>Judge ensembles and agreement between multiple judges&lt;/strong>: how to reduce bias by combining three heterogeneous models as a panel, which aggregation function works (simple majority, trimmed mean, calibrated meta-judge).&lt;/li>
&lt;li>&lt;strong>Meta-eval&lt;/strong>: how you evaluate the suite itself. If the eval improvement from adapter v7 to v8 does not translate into a real improvement for the user, the suite is wrong, and that is also measured, by correlating eval metrics against product metrics.&lt;/li>
&lt;li>&lt;strong>Metamorphic testing&lt;/strong>: evaluating robustness against input perturbations (typos, paraphrasing, alternative language) as an extra gate. Beyond nominal agreement, it measures consistency.&lt;/li>
&lt;li>&lt;strong>Adversarial evals with red teaming&lt;/strong>: introducing prompt injection and jailbreak attacks as part of the gate.&lt;/li>
&lt;li>&lt;strong>Privacy in the judge&lt;/strong>: how to stop sensitive outputs from the model under test travelling to an external judge when regulation forbids it — homomorphic judge, judge in a TEE, or simply a self-hosted judge.&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/six-stage-llmops-pipeline-architecture-and-deep-dive/">The six-stage LLMOps pipeline&lt;/a> — the master map where Eval sits between Tune and Deploy. The &amp;ldquo;Stage 3 — Eval&amp;rdquo; section gives the structured summary this post develops.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/anatomy-llm-request-production-tour-six-stages/">Anatomy of a production LLM request, May 2026&lt;/a> — the forensic tour of a request crossing the six stages; the moment the eval suite is invoked to promote adapter v7→v8 is the materialisation of the gate described here.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/prompt-versioning-contract-five-word-change/">Prompt versioning: the contract that stops a five-word change sinking your system&lt;/a> — judge prompts are versioned with the same mechanisms as any other prompt. The &lt;code>prompt_id&lt;/code> travels in lineage.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/data-versioning-llmops-dvc-lakefs-reproducible-golden-dataset/">Data versioning: DVC, lakeFS and the reproducible golden dataset challenge&lt;/a> — the golden eval set is one of the four artefacts to version differently. Without a strict holdout, the metric measures memorisation.&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> — the eval gates that appear in that post as SQL predicates are the concrete materialisation of the framework described here.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/retrain-closing-the-loop-feedback-dataset-adapter/">Retrain: closing the loop between the production incident and the adapter that fixes it&lt;/a> — the golden set is enriched with incidents coming from the Retrain loop; without that flow the dataset ages and pitfalls 1 and 5 trigger by themselves.&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> — entry-by-entry write-ups of DeepEval, Promptfoo, RAGAS, Langfuse, Phoenix.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/parallel-catalogue-llmops-stages-open-source-hyperscalers/">The parallel catalogue: OSS vs AWS / GCP / Azure&lt;/a> — how the Eval stage translates to Bedrock Evaluations, Vertex AI Eval Service and Azure AI Evaluation.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/llm-as-judge-exam-marker-not-oracle/">LLM-as-judge: the examination marker&lt;/a> — the judge piece of this mixed board taken apart in depth. G-Eval, Prometheus 2, panel of judges, the four biases (position, verbosity, self-preference, narcissism) and calibration with Cohen&amp;rsquo;s kappa.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/modern-alignment-dpo-kto-orpo-simpo/">Modern alignment: DPO, KTO, ORPO and SimPO&lt;/a> — the judge calibrated by the techniques here produces the preference pairs that enter as an alignment dataset. Closing the Eval → Tune loop.&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 capture substrate on which continuous eval runs. The traces that tail sampling preserves (errors, high latencies, guardrail blocks) are the natural input for the judge in production.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/langfuse-inside-sorting-centre-bottleneck/">Langfuse from the inside: v3 architecture and the 10 backend knobs&lt;/a> — the datasets and evaluators designed here rest on the Langfuse backend (ClickHouse + Postgres); that post explains how to size and tune it so it withstands the trace volume feeding the eval.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/structured-output-bitmask-outlines-xgrammar-llguidance/">Structured output&lt;/a> — LLM-as-judge evals benefit enormously from guaranteed structured output: a &lt;code>{score, justification, pass}&lt;/code> verdict that parses 100 % of the time removes retries and simplifies storage in the data warehouse for longitudinal analysis.&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 sibling layer at runtime. Same categories as those evaluated offline here (toxicity, PII, prompt injection, jailbreak, groundedness) but with a latency budget of 30-150 ms and compact models (PromptGuard 2, Llama Guard 4, Granite Guardian, ShieldGemma) instead of large judges.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/canary-blue-green-shadow-llm-models/">Canary, blue-green and shadow for LLM models&lt;/a> — the eval suite designed here is the gate &lt;strong>before&lt;/strong> the canary; unless the suite has passed offline on the candidate model, the rollout does not start.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/evaluating-rag-ragas-golden-dataset-metrics/">Evaluating a RAG without fooling yourself: RAGAS, the golden dataset and the four metrics that matter&lt;/a> — the specialisation of this post&amp;rsquo;s eval framework for RAG systems; faithfulness, context precision and context recall are the eval gate metrics when the system has retrieval.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/llm-quality-benchmarks-contamination-trap-oss-tools/">LLM quality benchmarks: MMLU, MT-Bench, HELM and the leaderboards that matter&lt;/a> — the external benchmarks that serve as a reference for calibrating whether internal evals are measuring the same thing as the field.&lt;/li>
&lt;/ul>
&lt;h2 id="references">References&lt;/h2>
&lt;ul>
&lt;li>&lt;strong>G-Eval&lt;/strong>: Liu et al., &amp;ldquo;G-Eval: NLG Evaluation using GPT-4 with Better Human Alignment&amp;rdquo; — the reference paper for the chain-of-thought LLM judge method.&lt;/li>
&lt;li>&lt;strong>Prometheus&lt;/strong>: Kim et al., &amp;ldquo;Prometheus 2: An Open Source Language Model Specialized in Evaluating Other Language Models&amp;rdquo; — OSS judge with a reported 0.897 correlation vs humans.&lt;/li>
&lt;li>&lt;strong>RAGAS&lt;/strong>: Es et al., &amp;ldquo;RAGAS: Automated Evaluation of Retrieval Augmented Generation&amp;rdquo; — the paper that standardises faithfulness, context relevancy and answer relevancy.&lt;/li>
&lt;li>&lt;strong>Cohen&amp;rsquo;s kappa&lt;/strong>: Cohen, J. (1960). &amp;ldquo;A Coefficient of Agreement for Nominal Scales.&amp;rdquo; Educational and Psychological Measurement — the classic metric for inter-annotator agreement, still the operational reference.&lt;/li>
&lt;li>&lt;strong>DeepEval docs&lt;/strong>: &lt;a href="https://docs.confident-ai.com/">https://docs.confident-ai.com/&lt;/a>&lt;/li>
&lt;li>&lt;strong>Promptfoo docs&lt;/strong>: &lt;a href="https://promptfoo.dev/docs/">https://promptfoo.dev/docs/&lt;/a>&lt;/li>
&lt;li>&lt;strong>Langfuse Evals&lt;/strong>: &lt;a href="https://langfuse.com/docs/scores">https://langfuse.com/docs/scores&lt;/a>&lt;/li>
&lt;li>&lt;strong>Inspect AI&lt;/strong>: &lt;a href="https://inspect.ai-safety-institute.org.uk/">https://inspect.ai-safety-institute.org.uk/&lt;/a>&lt;/li>
&lt;li>&lt;strong>EU AI Act&lt;/strong>, relevant articles on mandatory evaluation of high-risk systems — pending publication of the CEN/CENELEC technical standards on conformity assessment for GenAI.&lt;/li>
&lt;/ul></description></item><item><title>The OSS catalogue for LLMOps in six stages: tool by tool, what each one does and when to choose it</title><link>https://blog.lo0.es/en/posts/oss-catalogue-llmops-six-stages-tool-by-tool/</link><pubDate>Sat, 23 May 2026 07:30:00 +0200</pubDate><guid>https://blog.lo0.es/en/posts/oss-catalogue-llmops-six-stages-tool-by-tool/</guid><description>&lt;h2 id="tldr">TL;DR&lt;/h2>
&lt;p>For each of the six LLMOps stages (Data, Tune, Eval, Deploy, Observe, Retrain) and the two cross-cutting components (prompt + data versioning), the open source ecosystem has canonical pieces that this blog has been citing again and again. This post gathers them in one place with &lt;strong>cards of roughly 150 words per core tool&lt;/strong>: what it does, how it differs from the alternatives inside the same bucket, its &lt;strong>licence and governance model&lt;/strong>, and a typical gotcha that you only learn in production. Plus more alternatives as bullets, a &lt;strong>decision matrix per stage&lt;/strong> according to the case (small / large corpus, single tenant / multi-tenant…), a &lt;strong>diagram&lt;/strong> of the connected OSS stack and a &lt;strong>master table&lt;/strong> of licences / EE offerings. The intention: that the reader closes the post knowing what is available, which company maintains it, what gap each piece fills, and when to choose it. This is not opinion: it is a curated catalogue.&lt;/p>
&lt;h2 id="you-are-here-every-stage-but-by-oss-column">You are here: every stage, but by OSS column&lt;/h2>
&lt;p>This post shares a map with the two previous ones in the series, the six stages and the two cross-cutting pieces are all active, but it zooms in on the &lt;strong>open source column&lt;/strong>.&lt;/p>
&lt;div class="diagram" style="max-width:820px;margin:1rem auto;">
&lt;svg viewBox="0 0 820 220" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="OSS catalogue by LLMOps pipeline stage">
&lt;style>.box{stroke:#444;stroke-width:1.4;rx:6}.active{fill:#ff8a4c;stroke-width:2}.cross{fill:#ffe9d6;stroke:#c66;stroke-width:1.4;rx:6}.lbl{font:600 12px sans-serif;fill:#222}.sm{font:11px sans-serif;fill:#333}.tiny{font:10px sans-serif;fill:#444}.oss{fill:#dfe9f5;stroke:#356}&lt;/style>
&lt;text x="410" y="20" text-anchor="middle" class="lbl">OSS catalogue: the consultant's toolbox by stage&lt;/text>
&lt;rect x="20" y="38" width="120" height="32" class="box active"/>&lt;text x="80" y="58" text-anchor="middle" class="lbl">1 · Data&lt;/text>
&lt;rect x="150" y="38" width="120" height="32" class="box active"/>&lt;text x="210" y="58" text-anchor="middle" class="lbl">2 · Tune&lt;/text>
&lt;rect x="280" y="38" width="120" height="32" class="box active"/>&lt;text x="340" y="58" text-anchor="middle" class="lbl">3 · Eval&lt;/text>
&lt;rect x="410" y="38" width="120" height="32" class="box active"/>&lt;text x="470" y="58" text-anchor="middle" class="lbl">4 · Deploy&lt;/text>
&lt;rect x="540" y="38" width="120" height="32" class="box active"/>&lt;text x="600" y="58" text-anchor="middle" class="lbl">5 · Observe&lt;/text>
&lt;rect x="670" y="38" width="120" height="32" class="box active"/>&lt;text x="730" y="58" text-anchor="middle" class="lbl">6 · Retrain&lt;/text>
&lt;rect x="20" y="88" width="120" height="78" class="box oss"/>
&lt;text x="80" y="103" text-anchor="middle" class="tiny">DVC · lakeFS&lt;/text>
&lt;text x="80" y="117" text-anchor="middle" class="tiny">MinIO · Ceph&lt;/text>
&lt;text x="80" y="131" text-anchor="middle" class="tiny">Qdrant · pgvector&lt;/text>
&lt;text x="80" y="145" text-anchor="middle" class="tiny">Kafka · Flink&lt;/text>
&lt;text x="80" y="159" text-anchor="middle" class="tiny">Debezium · Karapace&lt;/text>
&lt;rect x="150" y="88" width="120" height="78" class="box oss"/>
&lt;text x="210" y="103" text-anchor="middle" class="tiny">HF Transformers&lt;/text>
&lt;text x="210" y="117" text-anchor="middle" class="tiny">PEFT · bitsandbytes&lt;/text>
&lt;text x="210" y="131" text-anchor="middle" class="tiny">DeepSpeed · FSDP&lt;/text>
&lt;text x="210" y="145" text-anchor="middle" class="tiny">Axolotl · LLaMA-Factory&lt;/text>
&lt;text x="210" y="159" text-anchor="middle" class="tiny">Ray Train · MLflow&lt;/text>
&lt;rect x="280" y="88" width="120" height="78" class="box oss"/>
&lt;text x="340" y="103" text-anchor="middle" class="tiny">DeepEval · RAGAS&lt;/text>
&lt;text x="340" y="117" text-anchor="middle" class="tiny">Promptfoo&lt;/text>
&lt;text x="340" y="131" text-anchor="middle" class="tiny">lm-eval-harness&lt;/text>
&lt;text x="340" y="145" text-anchor="middle" class="tiny">NeMo Guardrails&lt;/text>
&lt;text x="340" y="159" text-anchor="middle" class="tiny">Presidio · LlamaGuard&lt;/text>
&lt;rect x="410" y="88" width="120" height="78" class="box oss"/>
&lt;text x="470" y="103" text-anchor="middle" class="tiny">vLLM · TGI · SGLang&lt;/text>
&lt;text x="470" y="117" text-anchor="middle" class="tiny">TensorRT-LLM&lt;/text>
&lt;text x="470" y="131" text-anchor="middle" class="tiny">llama.cpp · Triton&lt;/text>
&lt;text x="470" y="145" text-anchor="middle" class="tiny">KServe · KubeRay&lt;/text>
&lt;text x="470" y="159" text-anchor="middle" class="tiny">Envoy AI · LiteLLM&lt;/text>
&lt;rect x="540" y="88" width="120" height="78" class="box oss"/>
&lt;text x="600" y="103" text-anchor="middle" class="tiny">OpenTelemetry&lt;/text>
&lt;text x="600" y="117" text-anchor="middle" class="tiny">Tempo · Jaeger&lt;/text>
&lt;text x="600" y="131" text-anchor="middle" class="tiny">Prometheus · Loki&lt;/text>
&lt;text x="600" y="145" text-anchor="middle" class="tiny">Langfuse · Phoenix&lt;/text>
&lt;text x="600" y="159" text-anchor="middle" class="tiny">Tetragon · Evidently&lt;/text>
&lt;rect x="670" y="88" width="120" height="78" class="box oss"/>
&lt;text x="730" y="103" text-anchor="middle" class="tiny">Airflow · Prefect&lt;/text>
&lt;text x="730" y="117" text-anchor="middle" class="tiny">Dagster · Argo&lt;/text>
&lt;text x="730" y="131" text-anchor="middle" class="tiny">Kubeflow Pipelines&lt;/text>
&lt;text x="730" y="145" text-anchor="middle" class="tiny">Feast&lt;/text>
&lt;text x="730" y="159" text-anchor="middle" class="tiny">Argilla · Label Studio&lt;/text>
&lt;rect x="20" y="180" width="380" height="22" class="cross"/>
&lt;text x="210" y="195" text-anchor="middle" class="sm">Prompt versioning: Langfuse Prompts · MLflow Prompt Registry&lt;/text>
&lt;rect x="410" y="180" width="380" height="22" class="cross"/>
&lt;text x="600" y="195" text-anchor="middle" class="sm">Data versioning: DVC · lakeFS · OpenLineage · DataHub&lt;/text>
&lt;/svg>
&lt;/div>
&lt;h2 id="the-analogy-the-electricians-toolbox">The analogy: the electrician&amp;rsquo;s toolbox&lt;/h2>
&lt;p>A professional electrician arrives at an installation with a box organised into compartments. Nothing is improvised: for every type of cable there is a specific wire stripper, for every screw a screwdriver of the exact gauge, for every measurement a multimeter and a clamp meter, for every connection the right terminal block or connector. The difference between a professional electrician and a handyman is not that one knows more theory, the handyman has often read the manuals, it is that &lt;strong>the right tool is within arm&amp;rsquo;s reach and he knows when to use each one&lt;/strong>. The day the specific stripper is missing, improvising with a box cutter breaks the insulation, leaves a badly terminated cable, and the panel comes back under warranty two months later.&lt;/p>
&lt;p>The OSS LLMOps stack works the same way. For every canonical problem, versioning a dataset, indexing a corpus for retrieval, serving tokens with dynamic batching, propagating &lt;code>trace_id&lt;/code> end-to-end, managing prompts with a &lt;code>production&lt;/code> label, orchestrating retraining pipelines, there is a canonical piece of the open source ecosystem that solves it, maintained by a serious community or foundation, with a clear licence and a well documented gotcha. The consultant who knows which tool to use for each job builds a robust system in weeks; the one who improvises with &amp;ldquo;whatever the team already knows&amp;rdquo; pays later in operations, usually once the system is carrying real load and any replacement is expensive.&lt;/p>
&lt;p>This post opens the toolbox and shows each card. It is not a user manual, that is what the deep-dive posts linked at the end are for; it is the &lt;strong>curated catalogue&lt;/strong>.&lt;/p>
&lt;h2 id="diagram-of-the-connected-reference-oss-stack">Diagram of the connected reference OSS stack&lt;/h2>
&lt;p>The catalogue makes sense once you see how the pieces connect into a single coherent architecture, the one this blog has been describing throughout the series. The boxes do not float; they talk to each other over stable contracts (HTTP, gRPC, OTel, Kafka, S3/MinIO API).&lt;/p>
&lt;div class="diagram" style="max-width:820px;margin:1rem auto;">
&lt;svg viewBox="0 0 820 460" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="reference OSS LLMOps stack with connected boxes">
&lt;style>.b{stroke:#333;stroke-width:1.4;rx:6;fill:#eef2f7}.serv{fill:#ff8a4c;stroke:#a44}.data{fill:#dfe9f5;stroke:#356}.obs{fill:#d8eecf;stroke:#373}.ctrl{fill:#f5e3d8;stroke:#763}.bg{fill:#fafafa;stroke:#ccc;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)}&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;/defs>
&lt;text x="410" y="20" text-anchor="middle" class="lbl">Connected OSS LLMOps stack: serving, data, observability and control plane&lt;/text>
&lt;rect x="20" y="40" width="780" height="80" class="b bg"/>
&lt;text x="30" y="58" class="tiny">SERVING PLANE&lt;/text>
&lt;rect x="40" y="68" width="120" height="40" class="b serv"/>&lt;text x="100" y="93" text-anchor="middle" class="sm">Envoy AI Gateway&lt;/text>
&lt;rect x="180" y="68" width="120" height="40" class="b serv"/>&lt;text x="240" y="93" text-anchor="middle" class="sm">vLLM (LoRA hot-swap)&lt;/text>
&lt;rect x="320" y="68" width="120" height="40" class="b serv"/>&lt;text x="380" y="93" text-anchor="middle" class="sm">KServe / Operator&lt;/text>
&lt;rect x="460" y="68" width="120" height="40" class="b serv"/>&lt;text x="520" y="93" text-anchor="middle" class="sm">Triton (multi-model)&lt;/text>
&lt;rect x="600" y="68" width="180" height="40" class="b serv"/>&lt;text x="690" y="93" text-anchor="middle" class="sm">Kubernetes (RKE2 + Cilium)&lt;/text>
&lt;path class="arr" d="M160,88 L180,88"/>&lt;path class="arr" d="M300,88 L320,88"/>
&lt;rect x="20" y="140" width="380" height="170" class="b bg"/>
&lt;text x="30" y="158" class="tiny">DATA PLANE&lt;/text>
&lt;rect x="40" y="168" width="160" height="36" class="b data"/>&lt;text x="120" y="190" text-anchor="middle" class="sm">PostgreSQL + pgvector&lt;/text>
&lt;rect x="220" y="168" width="160" height="36" class="b data"/>&lt;text x="300" y="190" text-anchor="middle" class="sm">Qdrant (RAG)&lt;/text>
&lt;rect x="40" y="214" width="160" height="36" class="b data"/>&lt;text x="120" y="236" text-anchor="middle" class="sm">MinIO / Ceph (S3)&lt;/text>
&lt;rect x="220" y="214" width="160" height="36" class="b data"/>&lt;text x="300" y="236" text-anchor="middle" class="sm">Kafka + Debezium&lt;/text>
&lt;rect x="40" y="260" width="160" height="36" class="b data"/>&lt;text x="120" y="282" text-anchor="middle" class="sm">DVC + lakeFS&lt;/text>
&lt;rect x="220" y="260" width="160" height="36" class="b data"/>&lt;text x="300" y="282" text-anchor="middle" class="sm">Flink / Spark&lt;/text>
&lt;path class="arr" d="M380,186 L460,186 L460,88 L460,108"/>
&lt;rect x="420" y="140" width="380" height="170" class="b bg"/>
&lt;text x="430" y="158" class="tiny">OBSERVABILITY PLANE&lt;/text>
&lt;rect x="440" y="168" width="160" height="36" class="b obs"/>&lt;text x="520" y="190" text-anchor="middle" class="sm">OTel Collector&lt;/text>
&lt;rect x="620" y="168" width="160" height="36" class="b obs"/>&lt;text x="700" y="190" text-anchor="middle" class="sm">Langfuse&lt;/text>
&lt;rect x="440" y="214" width="160" height="36" class="b obs"/>&lt;text x="520" y="236" text-anchor="middle" class="sm">Tempo (traces)&lt;/text>
&lt;rect x="620" y="214" width="160" height="36" class="b obs"/>&lt;text x="700" y="236" text-anchor="middle" class="sm">Phoenix Arize OSS&lt;/text>
&lt;rect x="440" y="260" width="160" height="36" class="b obs"/>&lt;text x="520" y="282" text-anchor="middle" class="sm">Prometheus + Grafana&lt;/text>
&lt;rect x="620" y="260" width="160" height="36" class="b obs"/>&lt;text x="700" y="282" text-anchor="middle" class="sm">Loki + Tetragon&lt;/text>
&lt;path class="otel" d="M240,108 L240,130 L460,130 L460,168"/>
&lt;text x="350" y="146" class="tiny" fill="#1a73e8">OTel spans (gen_ai.*)&lt;/text>
&lt;rect x="20" y="330" width="780" height="110" class="b bg"/>
&lt;text x="30" y="348" class="tiny">CONTROL PLANE (Tune + Eval + Retrain + Prompt versioning)&lt;/text>
&lt;rect x="40" y="358" width="140" height="36" class="b ctrl"/>&lt;text x="110" y="380" text-anchor="middle" class="sm">Axolotl + PEFT&lt;/text>
&lt;rect x="200" y="358" width="140" height="36" class="b ctrl"/>&lt;text x="270" y="380" text-anchor="middle" class="sm">MLflow Tracking&lt;/text>
&lt;rect x="360" y="358" width="140" height="36" class="b ctrl"/>&lt;text x="430" y="380" text-anchor="middle" class="sm">Promptfoo + RAGAS&lt;/text>
&lt;rect x="520" y="358" width="140" height="36" class="b ctrl"/>&lt;text x="590" y="380" text-anchor="middle" class="sm">Argo / Kubeflow Pipelines&lt;/text>
&lt;rect x="680" y="358" width="100" height="36" class="b ctrl"/>&lt;text x="730" y="380" text-anchor="middle" class="sm">Argilla&lt;/text>
&lt;rect x="40" y="402" width="140" height="30" class="b ctrl"/>&lt;text x="110" y="420" text-anchor="middle" class="sm">Langfuse Prompts&lt;/text>
&lt;rect x="200" y="402" width="140" height="30" class="b ctrl"/>&lt;text x="270" y="420" text-anchor="middle" class="sm">Feast&lt;/text>
&lt;rect x="360" y="402" width="140" height="30" class="b ctrl"/>&lt;text x="430" y="420" text-anchor="middle" class="sm">NeMo Guardrails&lt;/text>
&lt;rect x="520" y="402" width="140" height="30" class="b ctrl"/>&lt;text x="590" y="420" text-anchor="middle" class="sm">OpenLineage + DataHub&lt;/text>
&lt;rect x="680" y="402" width="100" height="30" class="b ctrl"/>&lt;text x="730" y="420" text-anchor="middle" class="sm">Presidio&lt;/text>
&lt;path class="arr" d="M110,358 L110,300 L110,300"/>&lt;path class="arr" d="M110,300 L110,260 L120,260"/>
&lt;/svg>
&lt;/div>
&lt;p>Solid arrows mark data / control flow; the dashed blue ones are OTel traces. The K8s plane holds everything up. The control plane at the bottom is where the retraining pipelines, the evals in CI, the versioned prompts and the lineage live. The data plane on the left feeds both the serving side (RAG, configs) and the control plane (datasets, lineage). The observability plane receives from serving and from everything else.&lt;/p>
&lt;p>Now we go stage by stage. Each one opens with a context paragraph, then cards for the core tools (about 150 words each), bullets with relevant alternatives, and a specific decision matrix at the end.&lt;/p>
&lt;h2 id="stage-1--data--cross-cutting-data-versioning">Stage 1 — Data + cross-cutting Data versioning&lt;/h2>
&lt;p>The Data stage solves three distinct problems that beginners confuse: &lt;strong>versioning&lt;/strong> datasets (so that &lt;code>(dataset_id, version, hash)&lt;/code> exists and propagates), &lt;strong>storing and serving&lt;/strong> the operational corpus (object store + vector index + structured text), and &lt;strong>moving it&lt;/strong> between systems with CDC and stable schemas. Covered in detail in the posts on &lt;a href="https://blog.lo0.es/en/posts/data-versioning-llmops-dvc-lakefs-reproducible-golden-dataset/">data versioning with DVC and lakeFS&lt;/a>, &lt;a href="https://blog.lo0.es/en/posts/postgresql-qdrant-rag-ingestion-postman-syncing-two-worlds/">PostgreSQL + Qdrant in ingestion&lt;/a> and &lt;a href="https://blog.lo0.es/en/posts/rag-on-kafka-reference-architecture-streaming-datalakes/">RAG over Kafka&lt;/a>.&lt;/p>
&lt;h3 id="dvc-data-version-control">DVC (Data Version Control)&lt;/h3>
&lt;p>DVC puts datasets under version control with the same discipline that git applies to code. The &lt;code>.dvc&lt;/code> pointers live in git (plain text, about 200 bytes per dataset), the bulk content lives in a remote object store (S3, MinIO, Azure Blob, GCS). Each &lt;code>dvc add&lt;/code> computes a SHA-256 hash of the dataset, uploads it to the remote and stores the pointer. The key line: the &lt;code>dataset_hash&lt;/code> becomes the luggage tag that travels to the trainer, to experiment tracking and to lineage. The same dataset retrained twice produces the same hash, and therefore reproducible experiments. DVC integrates with MLflow and W&amp;amp;B as an input artefact. &lt;strong>Gotcha:&lt;/strong> it works well for datasets that change by replacement (I swap &lt;code>train.jsonl&lt;/code> for a new version) and worse for datasets with thousands of small files that change individually. For that case, combine it with lakeFS. Licence &lt;strong>Apache 2.0&lt;/strong>, maintained by &lt;strong>Iterative.ai&lt;/strong> since 2017. There is DVC Studio (managed) and &lt;code>dvc data&lt;/code> (pure CLI) on different planes.&lt;/p>
&lt;h3 id="lakefs">lakeFS&lt;/h3>
&lt;p>lakeFS brings git semantics (branch, commit, merge, rollback) to an entire S3/MinIO/ADLS bucket. Where DVC versions individual files as pointers in git, lakeFS versions &lt;strong>the whole bucket&lt;/strong>: you can branch the corpus, ingest new data into the branch, validate that it passes checks (recall@10 over golden queries for embeddings, completeness for a tabular corpus), and only then merge into &lt;code>main&lt;/code>. It is the piece that makes continuous RAG safe: the production corpus is always on &lt;code>main&lt;/code>, updates are tested on branches. It offers hooks (pre-merge, pre-commit) that fire automatic validations, and time-travel to reproduce the state of the bucket at a past date. &lt;strong>Gotcha:&lt;/strong> the manifest overhead on huge buckets (hundreds of millions of objects) deserves sizing; lakeFS keeps metadata in its own Postgres, not in the bucket. Licence &lt;strong>Apache 2.0&lt;/strong>, maintained by &lt;strong>Treeverse&lt;/strong> since 2020. Managed offering: &lt;strong>lakeFS Cloud&lt;/strong>.&lt;/p>
&lt;h3 id="minio">MinIO&lt;/h3>
&lt;p>MinIO is the S3-compatible object store that fills the &amp;ldquo;S3 on-premise&amp;rdquo; gap without surprises. API identical to S3 (the AWS SDKs work by pointing them at a different endpoint), its own CLI client (&lt;code>mc&lt;/code>), erasure-coded mode for fault tolerance, bucket-to-bucket replication, encryption at rest. It is the base on which the other data plane components are built: DVC remote, lakeFS underlying storage, Postgres snapshots, MLflow artefacts, eval datasets, saved models, distributed KV cache fabric. In small deployments it runs single-node multi-disk; in serious ones, distributed clusters. &lt;strong>Gotcha:&lt;/strong> the licence changed to &lt;strong>AGPLv3&lt;/strong> in 2021 (it was Apache 2.0 before), which means distributing software connected to MinIO obliges you to open the code that connects to it. For internal on-premise use this is not a problem; for a vendor packaging MinIO inside a commercial product, it is. Maintained by &lt;strong>MinIO Inc.&lt;/strong> with a SUBNET enterprise offering and a community fork called &lt;strong>AIStor&lt;/strong> launched in 2025.&lt;/p>
&lt;h3 id="qdrant">Qdrant&lt;/h3>
&lt;p>Qdrant is the OSS vector database most aligned with this blog&amp;rsquo;s &amp;ldquo;RAG corpus per tenant with strict ACLs&amp;rdquo; pattern. Written in Rust, it exposes a REST + gRPC API, indexes with HNSW + scalar/binary quantisation to cut memory, supports efficient payload filtering (it is not post-filtering: it integrates the filter into the HNSW search), and allows collections isolated per tenant. For the &lt;a href="https://blog.lo0.es/en/posts/anatomy-llm-request-production-tour-six-stages/">multi-tenant chatbot&lt;/a> scenario, Qdrant is where the &lt;code>tenant_&amp;lt;id&amp;gt;_kb_v3&lt;/code> collections with strict ACL live. It scales well horizontally (sharding by payload) and vertically (millions of chunks on a node with 64GB RAM). &lt;strong>Gotcha:&lt;/strong> binary quantisation is aggressive, it cuts VRAM by 32× but degrades recall by 10-20 %; enabling it without re-tuning the threshold breaks retrieval silently. Licence &lt;strong>Apache 2.0&lt;/strong>, maintained by &lt;strong>Qdrant Solutions GmbH&lt;/strong> (Germany). There is Qdrant Cloud (managed) and EU-only support for ENS cases.&lt;/p>
&lt;h3 id="postgresql--pgvector">PostgreSQL + pgvector&lt;/h3>
&lt;p>Postgres 18 with the &lt;code>pgvector&lt;/code> extension is the stack&amp;rsquo;s &amp;ldquo;hidden vector database&amp;rdquo;: when the corpus is small (under a million embeddings) and Postgres is already in production for operational data, running a separate Qdrant is expensive operations. pgvector adds a &lt;code>vector(dim)&lt;/code> type, HNSW and IVF indexes, and the operators &lt;code>&amp;lt;-&amp;gt;&lt;/code>, &lt;code>&amp;lt;#&amp;gt;&lt;/code>, &lt;code>&amp;lt;=&amp;gt;&lt;/code> for cosine, L2 and dot product. Combined with &lt;code>tsvector&lt;/code> (Postgres full-text search) it allows &lt;strong>hybrid search&lt;/strong>, dense + sparse, in a single SQL query. Version 0.8 (2025) introduced halfvec and bit support to reduce size by 4×-8×. &lt;strong>Gotcha:&lt;/strong> HNSW in pgvector consumes a fair amount of RAM to build the index (roughly 2× the size of the embeddings) and blocks inserts during the build; in production you build on a secondary, promote it, and discard the primary. Licence &lt;strong>PostgreSQL License&lt;/strong> (permissive BSD-style) for both the core and pgvector. Maintained by the &lt;strong>PostgreSQL Development Group&lt;/strong> plus pgvector by &lt;strong>Andrew Kane&lt;/strong> + Crunchy Data + Neon.&lt;/p>
&lt;h3 id="apache-kafka--debezium">Apache Kafka + Debezium&lt;/h3>
&lt;p>Kafka is the event bus where &amp;ldquo;everything that happens in the company is a stream&amp;rdquo; becomes real. For LLMOps in production it serves two functions: &lt;strong>CDC from source systems&lt;/strong> (Debezium captures changes in Postgres / MySQL / MongoDB and publishes them as topics) and &lt;strong>LLM event buffer&lt;/strong> (every request, every piece of feedback, every eval result ends up in a topic with the &lt;code>trace_id&lt;/code> propagated). As the &lt;a href="https://blog.lo0.es/en/posts/rag-on-kafka-reference-architecture-streaming-datalakes/">post on RAG over Kafka&lt;/a> explains, the RAG corpus is kept fresh by capturing changes from the CMS / source system as CDC, running the embedding in Flink streaming, and ingesting into Qdrant continuously. &lt;strong>Gotcha:&lt;/strong> a badly sized Kafka with long retention plus multi-client topics turns into a fast disk sink; measuring throughput per topic and key cardinality before production is mandatory. Kafka licence &lt;strong>Apache 2.0&lt;/strong> (&lt;strong>ASF&lt;/strong> project); Debezium &lt;strong>Apache 2.0&lt;/strong> (project incubated by &lt;strong>Red Hat&lt;/strong>). Kafka-compatible drop-in alternative: &lt;strong>Redpanda&lt;/strong> (BSL, restricted commercial use).&lt;/p>
&lt;h3 id="apache-flink-brief-mention">Apache Flink (brief mention)&lt;/h3>
&lt;p>Flink processes streams with sub-second latency and exactly-once semantics. On the LLM plane it is used to: run embeddings in streaming (over CDC topics), aggregate online metrics, materialise features for retraining. Licence Apache 2.0, ASF. Common alternative: &lt;strong>Spark Structured Streaming&lt;/strong> (also ASF, micro-batch latency).&lt;/p>
&lt;p>&lt;strong>More options for Data&lt;/strong>, mentioned on the blog:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Ceph&lt;/strong> — object store for large clusters with geo-distributed replication. LGPL/Apache licence, &lt;strong>Red Hat / IBM&lt;/strong>.&lt;/li>
&lt;li>&lt;strong>Milvus&lt;/strong> — C++ vector database, an alternative to Qdrant; better for corpora of billions. Apache 2.0, &lt;strong>Zilliz&lt;/strong>.&lt;/li>
&lt;li>&lt;strong>Karapace&lt;/strong> — Confluent-compatible OSS Schema Registry. Apache 2.0, &lt;strong>Aiven&lt;/strong>.&lt;/li>
&lt;li>&lt;strong>DataHub / Apache Atlas / OpenMetadata&lt;/strong> — catalogue + lineage. Apache 2.0, &lt;strong>Acryl Data / ASF / Collate&lt;/strong> respectively.&lt;/li>
&lt;li>&lt;strong>OpenLineage&lt;/strong> — cross-system lineage event standard. Apache 2.0, &lt;strong>Linux Foundation AI&amp;amp;Data&lt;/strong>.&lt;/li>
&lt;/ul>
&lt;p>&lt;strong>Decision matrix — Data:&lt;/strong>&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>If your case is&lt;/th>
&lt;th>Choose&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Corpus &amp;lt; 1M embeddings, you already have Postgres&lt;/td>
&lt;td>&lt;strong>pgvector&lt;/strong> (one component fewer)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Corpus 1M-100M, multi-tenant with ACL&lt;/td>
&lt;td>&lt;strong>Qdrant&lt;/strong> (integrated filtering, ACLs per collection)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Corpus &amp;gt; 100M, aggressive sharding&lt;/td>
&lt;td>&lt;strong>Milvus&lt;/strong> (scales linearly better into the billions)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Training datasets + experiment tracking&lt;/td>
&lt;td>&lt;strong>DVC&lt;/strong> over MinIO + MLflow integration&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>RAG corpus with controlled releases&lt;/td>
&lt;td>&lt;strong>lakeFS&lt;/strong> over MinIO + pre-merge hooks&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>You want both&lt;/td>
&lt;td>&lt;strong>DVC + lakeFS&lt;/strong> as complements (the blog&amp;rsquo;s recommendation)&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;h2 id="stage-2--tune">Stage 2 — Tune&lt;/h2>
&lt;p>The Tune stage produces a new &lt;code>model_id, model_version&lt;/code>, typically a LoRA adapter over a stable base, with lineage back to the dataset and experiment tracking to reproduce it. Detail in the &lt;a href="https://blog.lo0.es/en/posts/continuous-fine-tuning-production-real-traffic-deployed-adapter/">post on continuous fine-tuning&lt;/a>.&lt;/p>
&lt;h3 id="huggingface-transformers--peft">HuggingFace Transformers + PEFT&lt;/h3>
&lt;p>&lt;code>transformers&lt;/code> is the canonical library for loading and training models of the decoder-only family (Llama, Mistral, Qwen, Gemma…) and encoder-decoder. &lt;code>peft&lt;/code> (Parameter-Efficient Fine-Tuning) is the complement that adds declarative support for LoRA, QLoRA, IA3 and assorted adapters. Together they form the &lt;strong>mandatory core&lt;/strong> of the OSS Tune stack: any higher-level framework (Axolotl, LLaMA-Factory) uses them underneath. PEFT allows you to train an adapter of about 280 MB (order of magnitude) instead of a full model of about 140 GB, with a functionally equivalent result on most style / domain adjustment tasks. &lt;strong>Gotcha:&lt;/strong> PEFT with a badly configured &lt;code>target_modules&lt;/code> trains an adapter that covers only Q and V of the attention, leaving out key, output proj and MLP. The result looks trained but performs poorly; adding &lt;code>target_modules=[&amp;quot;all-linear&amp;quot;]&lt;/code> fixes it (at the cost of a larger adapter). Licence &lt;strong>Apache 2.0&lt;/strong>, maintained by &lt;strong>Hugging Face SAS&lt;/strong> (a French company); open governance model with active external maintainers.&lt;/p>
&lt;h3 id="bitsandbytes">bitsandbytes&lt;/h3>
&lt;p>bitsandbytes implements weight quantisation to 8-bit and 4-bit with NF4 for models loaded with &lt;code>transformers&lt;/code>. It reduces the 140 GB of Llama 3 70B FP16 to about 40 GB in NF4, allowing QLoRA training on a single H100 80GB. The trick is that the weights stay quantised in memory while the sensitive computations (attention, gradient updates in the adapter) are done in FP16/BF16 with on-the-fly dequantisation. Ideal for fine-tuning on limited hardware and for serving with vLLM when you want to cut VRAM. &lt;strong>Gotcha:&lt;/strong> NF4 quantisation is lossy; on small models (&amp;lt; 7B) the quality degradation is noticeable. For production serving of models under 7B, INT8 is preferred (more memory, less loss) or FP8 if the hardware supports it (the H100 does). Licence &lt;strong>MIT&lt;/strong>, maintained by &lt;strong>Tim Dettmers&lt;/strong> (originally at U. Washington, now with support from Anthropic and HuggingFace).&lt;/p>
&lt;h3 id="mlflow-tracking">MLflow Tracking&lt;/h3>
&lt;p>MLflow is the reference OSS experiment tracking tool: each trainer run records parameters (lr, batch size, epochs, target_modules), metrics (loss curves, eval scores), artefacts (model, tokeniser, configs) and, crucially, &lt;strong>input artefacts&lt;/strong> (dataset_id, dataset_hash, parent_run). The model registry associates each &lt;code>model_version&lt;/code> with a reproducible &lt;code>run_id&lt;/code>. The line of continuity between Tune and Deploy passes through here: the deployment reads the model to serve from the registry, with its lineage made explicit. MLflow 2.x integrates &lt;strong>MLflow Prompts&lt;/strong> (prompt registry) and &lt;strong>MLflow Tracing&lt;/strong> (OTel-compatible spans), cutting the number of components needed. &lt;strong>Gotcha:&lt;/strong> the default backend store is SQLite, which works for personal experiments and breaks on a shared cluster. In production: Postgres as the backend store + MinIO/S3 as the artefact store. Licence &lt;strong>Apache 2.0&lt;/strong>, maintained by &lt;strong>LF AI &amp;amp; Data&lt;/strong> (donated by Databricks in 2020).&lt;/p>
&lt;h3 id="axolotl">Axolotl&lt;/h3>
&lt;p>Axolotl wraps &lt;code>transformers + PEFT + bitsandbytes + DeepSpeed + FSDP&lt;/code> in a declarative YAML configuration: instead of writing a script of about 300 lines to configure a fine-tuning run, you define a &lt;code>config.yml&lt;/code> with base model, dataset path, LoRA config and training hyperparameters, and run it in one line. It supports Llama, Mistral, Qwen, Gemma, Phi… loads. It keeps compatibility with the HuggingFace Hub to download models and datasets, and with MLflow / W&amp;amp;B for tracking. It is the convenience framework this blog cites when it talks about &amp;ldquo;productive fine-tuning without reinventing the wheel&amp;rdquo;. &lt;strong>Gotcha:&lt;/strong> the pace of change in the community is fast; a &lt;code>config.yml&lt;/code> that worked six months ago can break with a current version because of internal refactors. Pinning the exact Axolotl version in the environment mitigates this. Licence &lt;strong>Apache 2.0&lt;/strong>, maintained by the &lt;strong>OpenAccess AI Collective&lt;/strong> (community-driven). A very similar alternative, more widely used in China: &lt;strong>LLaMA-Factory&lt;/strong> (Apache 2.0, Beihang U.).&lt;/p>
&lt;h3 id="ray-train">Ray Train&lt;/h3>
&lt;p>Ray Train scales fine-tuning to multiple nodes by distributing workers across a Ray cluster. While DeepSpeed and FSDP are &lt;strong>intra-job parallelism&lt;/strong> (several GPUs collaborating on one job), Ray Train is the &lt;strong>orchestration plane&lt;/strong> that builds the cluster, launches workers, manages checkpoints, recovers from node failures, and integrates with Slurm or Kubernetes. For training runs beyond 8 GPUs on changing clusters, Ray Train avoids the operational burden of &amp;ldquo;manually launching N torchrun processes with NCCL&amp;rdquo;. It combines with MLflow for tracking. &lt;strong>Gotcha:&lt;/strong> the Ray learning curve is real; for a single node with 4-8 GPUs, &lt;code>torchrun&lt;/code> or Hugging Face Accelerate are simpler. Ray Train shines when there are N changing nodes. Licence &lt;strong>Apache 2.0&lt;/strong>, maintained by &lt;strong>Anyscale Inc.&lt;/strong> (commercial backer) + community. A more K8s-native alternative: &lt;strong>Kubeflow Training Operator&lt;/strong> (Apache 2.0, LF AI &amp;amp; Data).&lt;/p>
&lt;p>&lt;strong>More options for Tune:&lt;/strong>&lt;/p>
&lt;ul>
&lt;li>&lt;strong>DeepSpeed&lt;/strong> — ZeRO parallelism in 3 stages, mixed precision, CPU/NVMe offload. MIT, &lt;strong>Microsoft&lt;/strong>.&lt;/li>
&lt;li>&lt;strong>FSDP&lt;/strong> (Fully Sharded Data Parallel) — native PyTorch parallelism, an alternative to DeepSpeed. BSD, &lt;strong>Meta&lt;/strong>.&lt;/li>
&lt;li>&lt;strong>LLaMA-Factory&lt;/strong> — equivalent to Axolotl with a focus on the Llama family. Apache 2.0, &lt;strong>Beihang University&lt;/strong>.&lt;/li>
&lt;/ul>
&lt;p>&lt;strong>Decision matrix — Tune:&lt;/strong>&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>If your case is&lt;/th>
&lt;th>Choose&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Fine-tune on 1 GPU with 24GB (RTX 4090)&lt;/td>
&lt;td>&lt;strong>QLoRA with bitsandbytes NF4&lt;/strong> + Axolotl&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Fine-tune on 1 H100 80GB, models &amp;lt; 13B&lt;/td>
&lt;td>&lt;strong>LoRA bf16&lt;/strong> + Axolotl&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Fine-tune on 4-8 GPUs, single node&lt;/td>
&lt;td>&lt;strong>transformers + PEFT + Accelerate&lt;/strong> + MLflow&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Multi-node fine-tune on a K8s cluster&lt;/td>
&lt;td>&lt;strong>Kubeflow Training Operator&lt;/strong> or &lt;strong>Ray Train&lt;/strong>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Reproducible tracking is mandatory&lt;/td>
&lt;td>&lt;strong>MLflow + DVC input artefact&lt;/strong>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>You want the minimum viable&lt;/td>
&lt;td>&lt;strong>Axolotl + MLflow&lt;/strong>&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;h2 id="stage-3--eval--guardrails">Stage 3 — Eval + Guardrails&lt;/h2>
&lt;p>Eval validates candidates pre and post promotion against a golden set with operational metrics; Guardrails runs safety online. Detailed in the posts on &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>.&lt;/p>
&lt;h3 id="deepeval">DeepEval&lt;/h3>
&lt;p>DeepEval is the &amp;ldquo;pytest-style&amp;rdquo; OSS eval suite: you define tests with assertions over faithfulness, answer relevancy, contextual precision, hallucination rate, summarisation quality… and run them in CI. Each metric is an evaluator: some rule-based, others LLM-as-judge with auditable prompts. The philosophy is &amp;ldquo;evals as unit tests&amp;rdquo;: parameterisable by dataset, failable in CI, integrable with GitHub Actions. &lt;strong>Gotcha:&lt;/strong> LLM-as-judge metrics vary between judge model versions, so if the judge moves up a version, the thresholds lose their earlier statistical meaning. Explicit pinning of the judge model in config plus periodic threshold recalibration is mandatory discipline. Licence &lt;strong>Apache 2.0&lt;/strong>, maintained by &lt;strong>Confident AI&lt;/strong> (a company); parallel commercial SaaS offering. Comparable: &lt;strong>TruLens&lt;/strong> (MIT, &lt;strong>TruEra&lt;/strong>) and &lt;strong>G-Eval&lt;/strong> (academic).&lt;/p>
&lt;h3 id="ragas-rag-assessment">RAGAS (RAG Assessment)&lt;/h3>
&lt;p>RAGAS specialises in evaluating RAG pipelines. It defines four canonical metrics: &lt;strong>faithfulness&lt;/strong> (the answer is supported by the retrieved chunks), &lt;strong>answer relevancy&lt;/strong> (the answer addresses the query), &lt;strong>context precision&lt;/strong> (the retrieved chunks are relevant), &lt;strong>context recall&lt;/strong> (all the relevant chunks were retrieved). Each metric is computed with LLM-as-judge over a dataset of (query, context, expected answer). For a RAG system, RAGAS is the evaluator that measures whether retrieval is aligned with generation. It integrates with Langfuse and MLflow to store results. &lt;strong>Gotcha:&lt;/strong> RAGAS works well with golden sets of under 1000 examples; over huge golden sets the cost of the LLM judge per evaluation explodes, and the practice is to sample. Licence &lt;strong>Apache 2.0&lt;/strong>, maintained by &lt;strong>Exploding Gradients&lt;/strong> (the authors&amp;rsquo; company).&lt;/p>
&lt;h3 id="promptfoo">Promptfoo&lt;/h3>
&lt;p>Promptfoo is the declarative, CI-oriented evaluator: in &lt;code>promptfooconfig.yaml&lt;/code> you define a set of prompts and a set of assertions (contains text X, does not contain Y, faithfulness &amp;gt; 0.8, judge approves…), point at a provider (OpenAI compatible, vLLM, Ollama…), and &lt;code>promptfoo eval&lt;/code> runs the matrix of prompts × providers × assertions, returns a diff against the baseline and fails CI if anything regresses. It is the most &amp;ldquo;DevOps-friendly&amp;rdquo; piece of the evals ecosystem: it integrates trivially with GitHub Actions, GitLab CI or Jenkins. &lt;strong>Gotcha:&lt;/strong> assertion thresholds have to be calibrated with real data; starting at a default &lt;code>&amp;gt; 0.5&lt;/code> produces false positives that erode the team&amp;rsquo;s trust. Calibrate after the first week. Licence &lt;strong>MIT&lt;/strong>, maintained by &lt;strong>Promptfoo, Inc.&lt;/strong> (a company); a commercial Promptfoo Cloud SaaS offering exists but the OSS version is complete.&lt;/p>
&lt;h3 id="nemo-guardrails">NeMo Guardrails&lt;/h3>
&lt;p>NeMo Guardrails is NVIDIA&amp;rsquo;s framework for defining and enforcing policies in LLM systems through a DSL called &lt;strong>Colang&lt;/strong>. It lets you express rules such as &amp;ldquo;if the user asks about topic X, answer with template Y&amp;rdquo; or &amp;ldquo;if the model tries to do Z, block it&amp;rdquo; in a conversational-script syntax rather than in Python. It runs as middleware between the app and the model: input rails (validate what comes in), output rails (validate what goes out), dialog rails (control the flow). Designed for complex multi-turn systems where the policies are nontrivial. &lt;strong>Gotcha:&lt;/strong> Colang adds latency per turn (roughly 50-200 ms depending on the policy graph); for high-throughput conversational chat, dialog rails are disabled and only input + output remain. Licence &lt;strong>Apache 2.0&lt;/strong>, maintained by &lt;strong>NVIDIA&lt;/strong>.&lt;/p>
&lt;h3 id="microsoft-presidio">Microsoft Presidio&lt;/h3>
&lt;p>Presidio is the most mature OSS PII (Personally Identifiable Information) detector in the ecosystem. It detects DNI, NIE, IBAN, phone numbers, emails, physical addresses, credit card numbers, personal names, dates of birth… with recognisers based on regex + NER (spaCy) + custom validators. It supports &lt;strong>redaction&lt;/strong> (replace with placeholders), &lt;strong>masking&lt;/strong> (asterisks) or &lt;strong>deterministic anonymisation&lt;/strong> (repeatable hash). For ENS/NIS2 scenarios, it is the piece placed in front (on input) and behind (on output) the LLM to guarantee that no PII is processed or emitted. &lt;strong>Gotcha:&lt;/strong> the built-in recognisers cover English well and the rest badly; for Spanish, Catalan and Basque you have to add custom recognisers, which is disciplined work but doable. Licence &lt;strong>MIT&lt;/strong>, maintained by &lt;strong>Microsoft&lt;/strong>.&lt;/p>
&lt;p>&lt;strong>More options for Eval:&lt;/strong>&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Phoenix Arize OSS&lt;/strong> — combines tracing + evals, an alternative to Langfuse Evals. ELv2, &lt;strong>Arize AI&lt;/strong>.&lt;/li>
&lt;li>&lt;strong>lm-eval-harness&lt;/strong> — academic suite with standard benchmarks (MMLU, HellaSwag…). MIT, &lt;strong>EleutherAI&lt;/strong>.&lt;/li>
&lt;li>&lt;strong>HELM&lt;/strong> — holistic academic evals. Apache 2.0, &lt;strong>Stanford CRFM&lt;/strong>.&lt;/li>
&lt;li>&lt;strong>Guardrails AI&lt;/strong> — a pythonic alternative to NeMo Guardrails. Apache 2.0, &lt;strong>Guardrails AI Inc.&lt;/strong>.&lt;/li>
&lt;li>&lt;strong>LlamaGuard / PromptGuard / ShieldGemma&lt;/strong> — safety models, not frameworks. Open weights, Meta / Google.&lt;/li>
&lt;/ul>
&lt;p>&lt;strong>Decision matrix — Eval + Guardrails:&lt;/strong>&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>If your case is&lt;/th>
&lt;th>Choose&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Eval in CI, &amp;ldquo;pytest for LLMs&amp;rdquo; style&lt;/td>
&lt;td>&lt;strong>Promptfoo + GitHub Actions&lt;/strong>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Eval specific to a RAG pipeline&lt;/td>
&lt;td>&lt;strong>RAGAS + Langfuse datasets&lt;/strong>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>General eval with custom metrics&lt;/td>
&lt;td>&lt;strong>DeepEval + MLflow dataset&lt;/strong>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Dialog policy with declarative rules&lt;/td>
&lt;td>&lt;strong>NeMo Guardrails (Colang)&lt;/strong>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>PII redaction in/out only&lt;/td>
&lt;td>&lt;strong>Presidio&lt;/strong> (you do not need NeMo)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Open safety model in Spanish&lt;/td>
&lt;td>&lt;strong>LlamaGuard 3&lt;/strong> or &lt;strong>ShieldGemma&lt;/strong>&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;h2 id="stage-4--deploy">Stage 4 — Deploy&lt;/h2>
&lt;p>Deploy serves tokens to the user with predictable throughput and latency, adapter hot-swap and multi-tenancy where it applies. Covered in the posts on &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/llm-inference-operators-kubernetes/">LLM operators&lt;/a>, &lt;a href="https://blog.lo0.es/en/posts/gpu-cluster-as-multi-tenant-platform/">multi-tenant cluster&lt;/a>, &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/pagedattention-vllm-block-manager/">PagedAttention&lt;/a> and &lt;a href="https://blog.lo0.es/en/posts/disaggregated-serving-prefill-decode-specialised-pods/">disaggregated serving&lt;/a>.&lt;/p>
&lt;h3 id="vllm">vLLM&lt;/h3>
&lt;p>vLLM is &lt;strong>the&lt;/strong> reference OSS inference engine. It implements &lt;strong>PagedAttention&lt;/strong> (KV cache paging in the style of virtual memory, avoiding fragmentation), &lt;strong>continuous batching&lt;/strong> (requests join the batch as they arrive, rather than waiting for the next batch), &lt;strong>prefix caching&lt;/strong> (common prefixes, system prompts, do not recompute the KV cache), &lt;strong>LoRA hot-swap&lt;/strong> (&lt;code>--enable-lora&lt;/code> allows loading and unloading adapters without restarting the engine), an &lt;strong>OpenAI-compatible&lt;/strong> API, and &lt;strong>disaggregated prefill/decode&lt;/strong> support since 2025. It covers almost everything from Llama 3 / Mistral / Qwen / DeepSeek. &lt;strong>Gotcha:&lt;/strong> maximum throughput is only reached with &lt;code>--max-num-seqs&lt;/code> and &lt;code>--gpu-memory-utilization&lt;/code> tuned for the specific model and hardware; the defaults are conservative. The initial tuning session pays off: 2-3× throughput. Licence &lt;strong>Apache 2.0&lt;/strong>, originated at UC Berkeley, today maintained by the &lt;strong>vLLM Project / LF AI &amp;amp; Data&lt;/strong> plus a broad community (Red Hat, NVIDIA, AWS, IBM contribute). Serious alternatives in the same bucket: &lt;strong>TGI&lt;/strong> (Apache 2.0, &lt;strong>Hugging Face&lt;/strong>), &lt;strong>SGLang&lt;/strong> (Apache 2.0, &lt;strong>LMSys&lt;/strong>), &lt;strong>TensorRT-LLM&lt;/strong> (Apache 2.0, &lt;strong>NVIDIA&lt;/strong>, requires conversion).&lt;/p>
&lt;h3 id="kserve">KServe&lt;/h3>
&lt;p>KServe is the Kubernetes operator for serving ML models, LLMs included, in a declarative pattern: you define an &lt;code>InferenceService&lt;/code> YAML with the model and predictor (which can be vLLM, TGI, Triton, or a custom container) and KServe handles scheduling onto GPU nodes, autoscaling (scale-to-zero included), traffic splitting for canary, and model registry integration. It is the layer that standardises &amp;ldquo;how a model is deployed on K8s&amp;rdquo; across multiple engines, instead of inventing engine-specific YAML. It supports multi-model with &lt;strong>Inference Graphs&lt;/strong> (chaining preprocessor → model → postprocessor) and integrates with KEDA/Karpenter for GPU pool autoscaling. &lt;strong>Gotcha:&lt;/strong> scale-to-zero on GPU works badly in practice because the warm-up (loading weights into VRAM) takes tens of seconds; &lt;code>minReplicas: 1&lt;/code> is better. Licence &lt;strong>Apache 2.0&lt;/strong>, maintained by &lt;strong>Kubeflow / LF AI &amp;amp; Data&lt;/strong>. Alternatives: &lt;strong>KubeRay&lt;/strong> (Apache 2.0, &lt;strong>Anyscale&lt;/strong>), &lt;strong>llm-d&lt;/strong> (Apache 2.0, &lt;strong>CNCF&lt;/strong>), &lt;strong>KAITO&lt;/strong> (MIT, &lt;strong>Microsoft Azure&lt;/strong>).&lt;/p>
&lt;h3 id="triton-inference-server">Triton Inference Server&lt;/h3>
&lt;p>Triton serves heterogeneous models on a single backend: LLMs (via the vLLM or TensorRT-LLM backend), traditional models (ONNX, TorchScript, TensorFlow), custom models. For systems that mix LLM inference with traditional classifiers, embedding encoders, reranking models, OCR and so on, Triton avoids having N different engines in N pods. It supports ensemble models (chaining models in a single request), dynamic batching, model versioning, model warmup. &lt;strong>Gotcha:&lt;/strong> Triton is flexible but heavy to operate; for systems serving only LLMs, vLLM directly is simpler and more optimised. Triton shines when there is real heterogeneity. Licence &lt;strong>BSD-3-Clause&lt;/strong>, maintained by &lt;strong>NVIDIA&lt;/strong>.&lt;/p>
&lt;h3 id="envoy-ai-gateway">Envoy AI Gateway&lt;/h3>
&lt;p>Envoy AI Gateway is the CNCF ecosystem&amp;rsquo;s &amp;ldquo;LLM-aware API gateway&amp;rdquo;. Built on Envoy Proxy, it adds knowledge of the OpenAI-compatible APIs (chat completions, embeddings, etc.), routing across multiple backends (local vLLM + OpenAI + Anthropic + Bedrock), &lt;strong>token-based rate limiting&lt;/strong> (limits by tokens per minute, not by requests), intelligent retries, fallback between providers, and built-in OTel observability. It is the piece that makes &amp;ldquo;AI Gateway&amp;rdquo; real as an architectural category. &lt;strong>Gotcha:&lt;/strong> integration with authentication (OIDC, JWT) is flexible but requires detailed Envoy configuration; an &amp;ldquo;out of the box&amp;rdquo; AI Gateway with no configuration produces an Envoy that passes everything through. Licence &lt;strong>Apache 2.0&lt;/strong>, maintained by &lt;strong>CNCF&lt;/strong> since the initial donation from Tetrate. Alternatives: &lt;strong>LiteLLM Proxy&lt;/strong> (MIT, &lt;strong>BerriAI&lt;/strong>), &lt;strong>Portkey&lt;/strong> (MIT, &lt;strong>Portkey AI&lt;/strong>), &lt;strong>Kong AI Gateway&lt;/strong> (Apache 2.0 base + EE, &lt;strong>Kong Inc.&lt;/strong>).&lt;/p>
&lt;h3 id="llamacpp">llama.cpp&lt;/h3>
&lt;p>llama.cpp serves LLMs on CPUs (and Apple Silicon, GPUs via Vulkan/Metal/CUDA) with very aggressive quantisation (GGUF format, down to 2-bit). It is the canonical option for inference on hardware without a dedicated GPU: edge devices, workstations, development machines. It covers everything from small models (Phi-3, Gemma 2B) to Llama 70B on hardware with enough RAM. &lt;strong>Gotcha:&lt;/strong> latency on CPU is orders of magnitude worse than on a dedicated GPU, so it is useful for offline evals, drift checks and local development, not for production serving under real load. Licence &lt;strong>MIT&lt;/strong>, maintained by &lt;strong>Georgi Gerganov&lt;/strong> + community.&lt;/p>
&lt;p>&lt;strong>More options for Deploy:&lt;/strong>&lt;/p>
&lt;ul>
&lt;li>&lt;strong>TensorRT-LLM&lt;/strong> — maximum optimisation on NVIDIA Hopper/Ada. Apache 2.0, NVIDIA.&lt;/li>
&lt;li>&lt;strong>SGLang&lt;/strong> — good for workloads with structured generation and JSON. Apache 2.0, LMSys.&lt;/li>
&lt;li>&lt;strong>TGI&lt;/strong> — a mature alternative, focused on the HuggingFace ecosystem. Apache 2.0, HuggingFace.&lt;/li>
&lt;li>&lt;strong>NVIDIA Dynamo&lt;/strong> — multi-node disaggregated serving. Apache 2.0, NVIDIA.&lt;/li>
&lt;li>&lt;strong>llm-d&lt;/strong> — K8s operator specific to LLMs. Apache 2.0, CNCF.&lt;/li>
&lt;/ul>
&lt;p>&lt;strong>Decision matrix — Deploy:&lt;/strong>&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>If your case is&lt;/th>
&lt;th>Choose&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Production serving on NVIDIA H100/A100&lt;/td>
&lt;td>&lt;strong>vLLM&lt;/strong> (the safe default)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Absolute throughput squeezing on Hopper&lt;/td>
&lt;td>&lt;strong>TensorRT-LLM&lt;/strong> + vLLM plugin or standalone&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Edge / local dev without GPU&lt;/td>
&lt;td>&lt;strong>llama.cpp&lt;/strong>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Multi-model (LLM + classifiers + encoders)&lt;/td>
&lt;td>&lt;strong>Triton&lt;/strong> with the vLLM backend&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Declarative K8s with autoscaling&lt;/td>
&lt;td>&lt;strong>KServe&lt;/strong> + vLLM as predictor&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>AI Gateway with token rate limiting&lt;/td>
&lt;td>&lt;strong>Envoy AI Gateway&lt;/strong>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Multi-node disaggregated GPU cluster&lt;/td>
&lt;td>&lt;strong>NVIDIA Dynamo&lt;/strong> over vLLM&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;h2 id="stage-5--observe">Stage 5 — Observe&lt;/h2>
&lt;p>Observe propagates &lt;code>trace_id&lt;/code> end-to-end, emits runtime metrics, runs an LLM judge over sampling and detects drift. Detailed in &lt;a href="https://blog.lo0.es/en/posts/agentsight-llm-tracing-ebpf-zero-instrumentation/">tracing with AgentSight&lt;/a>, &lt;a href="https://blog.lo0.es/en/posts/mcp-deep-observability-opentelemetry/">MCP observability with OTel&lt;/a> and &lt;a href="https://blog.lo0.es/en/posts/ebpf-local-inference-statistical-drift-detection/">eBPF + drift&lt;/a>.&lt;/p>
&lt;h3 id="opentelemetry-collector">OpenTelemetry Collector&lt;/h3>
&lt;p>The OTel Collector is the agent that receives traces, metrics and logs in OTel format (or in any other via receivers), processes them (filters, sampling, attribute enrichment, PII redaction), and routes them to one or several backends (Tempo, Jaeger, Prometheus, Loki, Langfuse…). It is &lt;strong>the piece that decouples the apps from the observability backend&lt;/strong>: moving from Tempo to Jaeger means changing the Collector&amp;rsquo;s exporter, not the app. For LLMOps it matters especially because the &lt;strong>OTel GenAI semantic conventions&lt;/strong> spec defines the attributes &lt;code>gen_ai.request.model&lt;/code>, &lt;code>gen_ai.prompt.version&lt;/code>, &lt;code>gen_ai.response.tokens&lt;/code> and so on, which stitch the &lt;code>trace_id&lt;/code> to the system&amp;rsquo;s lineage. &lt;strong>Gotcha:&lt;/strong> the Collector configuration tends to grow; without discipline and periodic review it ends up as an 800-line YAML that nobody understands. Modularising with &lt;code>extensions&lt;/code> helps. Licence &lt;strong>Apache 2.0&lt;/strong>, maintained by the &lt;strong>CNCF / OpenTelemetry Project&lt;/strong>.&lt;/p>
&lt;h3 id="tempo-traces--jaeger">Tempo (traces) + Jaeger&lt;/h3>
&lt;p>Grafana Tempo is the distributed trace backend optimised for cost: it uses an object store (S3/MinIO) instead of Elasticsearch, deduplicates by &lt;code>trace_id&lt;/code>, and integrates natively with Grafana for visualisation. For LLMOps, where a real request generates 10-30 spans (gateway, prompt pull, RAG retrieval, prefill, decode N times, scoring), Tempo copes with high volumes at a reasonable cost. &lt;strong>Jaeger&lt;/strong> is the more established CNCF alternative, better for cases under 100k traces per day, worse for native object store. &lt;strong>Gotcha:&lt;/strong> Tempo has no traditional indexing; searches like &amp;ldquo;traces that took more than 5s and touched tenant X&amp;rdquo; require &lt;strong>TraceQL&lt;/strong> + Grafana and are not as fast as Jaeger with Elasticsearch. For immediate ad-hoc diagnosis it is worth keeping a parallel Jaeger with aggressive sampling. Licences &lt;strong>AGPL 3.0&lt;/strong> (Tempo) and &lt;strong>Apache 2.0&lt;/strong> (Jaeger), maintained by &lt;strong>Grafana Labs&lt;/strong> and &lt;strong>CNCF&lt;/strong> respectively.&lt;/p>
&lt;h3 id="prometheus--grafana">Prometheus + Grafana&lt;/h3>
&lt;p>Prometheus is the time-series metrics foundation of the ecosystem. Pull model (it scrapes &lt;code>/metrics&lt;/code> endpoints), PromQL for queries, exporters for everything (Postgres, Kafka, NVIDIA GPU via &lt;code>dcgm-exporter&lt;/code>, vLLM natively). Grafana visualises Prometheus + Tempo + Loki on a single plane. For LLMOps, the critical metrics are &lt;code>gpu_utilization&lt;/code>, &lt;code>kv_cache_usage_pct&lt;/code>, &lt;code>tokens_per_second&lt;/code>, &lt;code>prefill_latency_p95&lt;/code>, &lt;code>decode_latency_p95&lt;/code>, &lt;code>queue_depth&lt;/code>, aggregated by tenant. &lt;strong>Gotcha:&lt;/strong> Prometheus is very good up to about 1M active series; above that it is worth moving to &lt;strong>Thanos&lt;/strong> or &lt;strong>Mimir&lt;/strong> for long retention and horizontal scalability. For the blog&amp;rsquo;s typical LLM cluster (4-8 H100), Prometheus alone is enough. Licences &lt;strong>Apache 2.0&lt;/strong> (Prometheus, &lt;strong>CNCF&lt;/strong>) and &lt;strong>AGPL 3.0&lt;/strong> (Grafana 10+, &lt;strong>Grafana Labs&lt;/strong>).&lt;/p>
&lt;h3 id="langfuse">Langfuse&lt;/h3>
&lt;p>Langfuse is the LLM-specific OSS observability + prompt management tool. It captures spans with LLM semantic conventions (input, output, model, tokens, latency, score, user_id, session_id), visualises them as &lt;strong>conversational traces&lt;/strong> (not just span trees), manages &lt;strong>prompts versioned with a &lt;code>production&lt;/code> label&lt;/strong> and allows &lt;strong>curated datasets + evals&lt;/strong> from the same UI. For serious LLMOps, Langfuse fills the gap that neither Tempo nor Jaeger covers: a tracing UI designed to be LLM-first. &lt;strong>Gotcha:&lt;/strong> Langfuse maintains its own store (Postgres + ClickHouse for high volume); on large clusters the operational side of ClickHouse deserves attention. To get started, Postgres alone copes. Licence &lt;strong>MIT&lt;/strong> for the OSS core, &lt;strong>EE Enterprise Edition&lt;/strong> with additional features (SSO, audit logs, advanced RBAC). Maintained by &lt;strong>Langfuse GmbH&lt;/strong> (Berlin, German). There is Langfuse Cloud (SaaS).&lt;/p>
&lt;h3 id="phoenix-arize-oss">Phoenix Arize OSS&lt;/h3>
&lt;p>Phoenix is Arize AI&amp;rsquo;s OSS for LLM observability + evals, an alternative to Langfuse with a different emphasis: more oriented to evaluation and visual debugging (embedding drift, cluster analysis), less to prompt management. A good pairing with Langfuse when you want a dual approach: Langfuse for &amp;ldquo;production conversational traces&amp;rdquo;, Phoenix for &amp;ldquo;exploratory investigation of model behaviour&amp;rdquo;. &lt;strong>Gotcha:&lt;/strong> Phoenix duplicates functionality with Langfuse and with MLflow; having all three in production multiplies the operational burden. Pick one as the main tool and the others as complements. Licence &lt;strong>Elastic License 2.0&lt;/strong> (not strictly OSI), maintained by &lt;strong>Arize AI&lt;/strong>.&lt;/p>
&lt;h3 id="cilium-tetragon--hubble">Cilium Tetragon + Hubble&lt;/h3>
&lt;p>Tetragon (eBPF runtime security observer) and Hubble (eBPF network observer) are the low-level pieces that give real runtime visibility into the cluster: which processes run in which pods, what syscalls they make, what network connections they open, in real time. For ENS/NIS2 environments that demand &amp;ldquo;prove what ran in production&amp;rdquo;, Tetragon is the irrefutable audit layer: every process execution with its parent, its capabilities, its K8s context. Hubble visualises network flows by pod, namespace, service. &lt;strong>Gotcha:&lt;/strong> the volume of events generated is high; without kernel-side filtering (which Tetragon supports with &lt;code>TracingPolicy&lt;/code>), it saturates the observability plane fast. Discipline in policies. Licence &lt;strong>Apache 2.0&lt;/strong> for both, maintained by &lt;strong>Cilium / CNCF / Isovalent&lt;/strong>.&lt;/p>
&lt;h3 id="evidently-ai">Evidently AI&lt;/h3>
&lt;p>Evidently is the OSS library for &lt;strong>drift detection&lt;/strong>: it compares input and output distributions between two time windows (training vs production, current week vs previous week), applies statistical tests (KS, PSI, Wasserstein, chi-square) and generates HTML reports. For LLMOps it detects when the distribution of prompts changes (new topics, new lengths, new languages) or when the model starts answering shorter / longer / differently. &lt;strong>Gotcha:&lt;/strong> Evidently is oriented to tabular data and embeddings; for raw text it is best combined with an encoder embedder that produces vectors before applying the tests. Licence &lt;strong>Apache 2.0&lt;/strong>, maintained by &lt;strong>Evidently AI&lt;/strong> (a company). Alternatives: &lt;strong>NannyML&lt;/strong> (Apache 2.0, &lt;strong>NannyML BV&lt;/strong>), &lt;strong>Alibi Detect&lt;/strong> (Apache 2.0, &lt;strong>Seldon&lt;/strong>).&lt;/p>
&lt;p>&lt;strong>More options for Observe:&lt;/strong>&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Loki&lt;/strong> — Prometheus-style logs backend for Grafana. AGPL 3.0, Grafana Labs.&lt;/li>
&lt;li>&lt;strong>Pixie&lt;/strong> — auto-instrumented eBPF observability. Apache 2.0, CNCF.&lt;/li>
&lt;/ul>
&lt;p>&lt;strong>Decision matrix — Observe:&lt;/strong>&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>If your case is&lt;/th>
&lt;th>Choose&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Minimum viable stack&lt;/td>
&lt;td>&lt;strong>OTel Collector + Tempo + Prometheus + Grafana + Langfuse&lt;/strong>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Traces with strong ad-hoc search&lt;/td>
&lt;td>Add &lt;strong>Jaeger&lt;/strong> with aggressive sampling&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>ENS / NIS2 compliance runtime audit&lt;/td>
&lt;td>&lt;strong>Tetragon + Hubble&lt;/strong> + mandated retention&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Exploratory investigation of the model&lt;/td>
&lt;td>&lt;strong>Phoenix Arize OSS&lt;/strong> alongside Langfuse&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Statistical drift detection&lt;/td>
&lt;td>&lt;strong>Evidently&lt;/strong> over embeddings + inputs&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Cluster beyond 1M Prometheus series&lt;/td>
&lt;td>&lt;strong>Mimir&lt;/strong> (Grafana Labs) or &lt;strong>Thanos&lt;/strong>&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;h2 id="stage-6--retrain--cross-cutting-pieces">Stage 6 — Retrain + cross-cutting pieces&lt;/h2>
&lt;p>Retrain closes the loop feedback → triage → enriched dataset → new adapter. Prompt versioning and data versioning stitch lineage across stages. Detailed in &lt;a href="https://blog.lo0.es/en/posts/retrain-closing-the-loop-feedback-dataset-adapter/">retrain&lt;/a>, &lt;a href="https://blog.lo0.es/en/posts/prompt-versioning-contract-five-word-change/">prompt versioning&lt;/a> and &lt;a href="https://blog.lo0.es/en/posts/data-versioning-llmops-dvc-lakefs-reproducible-golden-dataset/">data versioning&lt;/a>.&lt;/p>
&lt;h3 id="apache-airflow">Apache Airflow&lt;/h3>
&lt;p>Airflow is the most established OSS DAG scheduler. You define workflows as Python code (DAGs), each DAG with tasks (operators) that run according to declared dependencies plus a cron schedule. For retraining: a weekly DAG that extracts feedback from Postgres, triages it with an LLM-as-classifier, enriches the enriched dataset in DVC, launches the fine-tuning job on Kubernetes, runs evals against the golden set, and promotes if it passes the gates. There is a huge ecosystem of operators for everything (S3, Postgres, Kafka, Slack, K8s, Spark…). &lt;strong>Gotcha:&lt;/strong> Airflow 2.x improved a great deal over the chaos of 1.x, but the scheduler is still a component that deserves operational attention (Postgres backend, executor pool, sidecar workers); for simple pipelines it is over-engineering. Licence &lt;strong>Apache 2.0&lt;/strong>, maintained by the &lt;strong>ASF&lt;/strong>.&lt;/p>
&lt;h3 id="argo-workflows">Argo Workflows&lt;/h3>
&lt;p>Argo Workflows is the K8s-native equivalent of Airflow: each step is a container, the DAGs are defined as K8s YAML, the executor is Kubernetes itself. For environments where &lt;strong>everything is K8s&lt;/strong>, Argo fits without an extra component to maintain. Long tasks (a six-hour fine-tuning run) execute as Pods that survive control plane failures. It integrates trivially with Kubeflow Pipelines (which is built on top). &lt;strong>Gotcha:&lt;/strong> Argo&amp;rsquo;s YAML syntax is verbose; for complex DAGs, Argo feels less productive than Airflow in Python. Solutions: &lt;strong>Hera&lt;/strong> (Python DSL for Argo, a &lt;strong>DataBricks contribution&lt;/strong>) or &lt;strong>Argo + custom CRDs&lt;/strong>. Licence &lt;strong>Apache 2.0&lt;/strong>, maintained by &lt;strong>CNCF&lt;/strong>.&lt;/p>
&lt;h3 id="kubeflow-pipelines">Kubeflow Pipelines&lt;/h3>
&lt;p>Kubeflow Pipelines is the layer above Argo Workflows oriented specifically to ML: artefact tracking, experiment tracking, reusable pipeline templates, versioned components. Built on Argo, it adds the ML conceptual model (input artefact, output artefact, metrics) that raw Argo does not have. For cyclical retraining on a K8s cluster, it is the most &amp;ldquo;ML-ready&amp;rdquo; option in the OSS ecosystem. &lt;strong>Gotcha:&lt;/strong> Kubeflow as a full suite is heavy (10+ components); many organisations install only Pipelines + Training Operator + Katib and skip Notebook Server / legacy KFServing. Licence &lt;strong>Apache 2.0&lt;/strong>, maintained by &lt;strong>CNCF / LF AI &amp;amp; Data&lt;/strong>.&lt;/p>
&lt;h3 id="feast">Feast&lt;/h3>
&lt;p>Feast is the most widely used OSS feature store. It defines &lt;strong>feature views&lt;/strong> over batch sources (BigQuery, Postgres, Parquet) and online sources (Redis, DynamoDB, Postgres with an extension), exposes a consistent API for read-during-training and read-during-inference (point-in-time correctness), and guarantees that the features of the model in production are the same ones it was trained with. For LLMOps where the model needs consistent user / session / context features (latest plan, tenure as a customer, recent tickets), Feast provides the discipline. &lt;strong>Gotcha:&lt;/strong> for many pure LLM systems (a RAG chatbot with no complex features), Feast is over-engineering, Postgres is enough. When there are real features (recommendation, scoring, ranking), Feast shines. Licence &lt;strong>Apache 2.0&lt;/strong>, maintained by &lt;strong>LF AI &amp;amp; Data&lt;/strong>.&lt;/p>
&lt;h3 id="argilla">Argilla&lt;/h3>
&lt;p>Argilla is the OSS annotation + HiL (human-in-the-loop) platform most aligned with modern LLMOps. It creates annotation projects with templates (classification, ranking, span annotation, RLHF preference, free-form text), connects to HuggingFace datasets, and integrates with Langfuse to import traces from production as cases to annotate. It supports multiple annotators with reconciliation, kappa scoring and quality control. To enrich retrain datasets with cases from the &amp;ldquo;blunt tone&amp;rdquo; cluster of the &lt;a href="https://blog.lo0.es/en/posts/retrain-closing-the-loop-feedback-dataset-adapter/">Retrain post&lt;/a>, Argilla is the front end. &lt;strong>Gotcha:&lt;/strong> Argilla requires Elasticsearch for production performance; for small experiments SQLite is enough. Licence &lt;strong>Apache 2.0&lt;/strong>, maintained by &lt;strong>Argilla, Inc.&lt;/strong> (acquired by &lt;strong>Hugging Face&lt;/strong> in 2024). Alternative: &lt;strong>Label Studio&lt;/strong> (Apache 2.0, &lt;strong>HumanSignal&lt;/strong>), more generalist, less LLM-first.&lt;/p>
&lt;h3 id="langfuse-prompts--mlflow-prompt-registry">Langfuse Prompts + MLflow Prompt Registry&lt;/h3>
&lt;p>Langfuse Prompts manages prompts as versioned entities with labels (production, staging, experiment). The client reads the active prompt from Langfuse on the request path (with a local cache of a few seconds) and propagates &lt;code>prompt_id, prompt_version&lt;/code> to the OTel span, exactly as the &lt;a href="https://blog.lo0.es/en/posts/anatomy-llm-request-production-tour-six-stages/">forensic post&lt;/a> does. MLflow Prompt Registry does the same with a slightly different conceptual model (no labels-as-pointers; it uses stages, like the Models registry). Both are valid; the choice depends on which tracking tool is already in place. &lt;strong>Gotcha (Langfuse):&lt;/strong> labels are mutable, so changing &lt;code>production&lt;/code> points at another version with no explicit audit; prompts are better deployed via PR against the configs repo, not manually in the UI. Licences and governance covered above.&lt;/p>
&lt;p>&lt;strong>More options for Retrain + cross-cutting pieces:&lt;/strong>&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Prefect&lt;/strong> — &amp;ldquo;modern&amp;rdquo; Python DAGs, an alternative to Airflow. Apache 2.0, &lt;strong>Prefect Tech&lt;/strong>.&lt;/li>
&lt;li>&lt;strong>Dagster&lt;/strong> — DAGs with a strong focus on data assets. Apache 2.0, &lt;strong>Dagster Labs&lt;/strong>.&lt;/li>
&lt;li>&lt;strong>Label Studio&lt;/strong> — generalist annotation. Apache 2.0, &lt;strong>HumanSignal&lt;/strong>.&lt;/li>
&lt;li>&lt;strong>OpenLineage&lt;/strong> — cross-system lineage event standard. Apache 2.0, &lt;strong>LF AI &amp;amp; Data&lt;/strong>.&lt;/li>
&lt;li>&lt;strong>DataHub / Apache Atlas / OpenMetadata&lt;/strong> — catalogue + lineage with a UI. Apache 2.0.&lt;/li>
&lt;/ul>
&lt;p>&lt;strong>Decision matrix — Retrain + cross-cutting pieces:&lt;/strong>&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>If your case is&lt;/th>
&lt;th>Choose&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Simple pipelines with a catalogue of operators&lt;/td>
&lt;td>&lt;strong>Airflow&lt;/strong>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Everything is K8s, minimal components&lt;/td>
&lt;td>&lt;strong>Argo Workflows&lt;/strong>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>ML pipelines with artefact tracking&lt;/td>
&lt;td>&lt;strong>Kubeflow Pipelines&lt;/strong>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>HiL annotation for LLM retraining&lt;/td>
&lt;td>&lt;strong>Argilla&lt;/strong> + Langfuse integration&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Features shared between training and inference&lt;/td>
&lt;td>&lt;strong>Feast&lt;/strong>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>No complex features, only prompts + LLM&lt;/td>
&lt;td>Skip Feast&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Lightweight prompt registry&lt;/td>
&lt;td>&lt;strong>Langfuse Prompts&lt;/strong>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>MLflow is already centralised&lt;/td>
&lt;td>&lt;strong>MLflow Prompt Registry&lt;/strong>&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;h2 id="master-table-licence-governance-and-enterprise-offering">Master table: licence, governance and enterprise offering&lt;/h2>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Tool&lt;/th>
&lt;th>Licence&lt;/th>
&lt;th>Governance / maintainer&lt;/th>
&lt;th>Commercial EE / SaaS&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>&lt;strong>DVC&lt;/strong>&lt;/td>
&lt;td>Apache 2.0&lt;/td>
&lt;td>Iterative.ai&lt;/td>
&lt;td>DVC Studio&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>lakeFS&lt;/strong>&lt;/td>
&lt;td>Apache 2.0&lt;/td>
&lt;td>Treeverse&lt;/td>
&lt;td>lakeFS Cloud&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>MinIO&lt;/strong>&lt;/td>
&lt;td>AGPL v3&lt;/td>
&lt;td>MinIO Inc.&lt;/td>
&lt;td>SUBNET / AIStor&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Qdrant&lt;/strong>&lt;/td>
&lt;td>Apache 2.0&lt;/td>
&lt;td>Qdrant GmbH&lt;/td>
&lt;td>Qdrant Cloud&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>pgvector&lt;/strong>&lt;/td>
&lt;td>PostgreSQL License&lt;/td>
&lt;td>Andrew Kane + community&lt;/td>
&lt;td>— (built-in Postgres clouds)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>PostgreSQL&lt;/strong>&lt;/td>
&lt;td>PostgreSQL License&lt;/td>
&lt;td>PostgreSQL Global Dev Group&lt;/td>
&lt;td>several managed (Crunchy, Neon, Aiven, EDB)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Apache Kafka&lt;/strong>&lt;/td>
&lt;td>Apache 2.0&lt;/td>
&lt;td>ASF&lt;/td>
&lt;td>Confluent Cloud&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Debezium&lt;/strong>&lt;/td>
&lt;td>Apache 2.0&lt;/td>
&lt;td>Red Hat / ASF&lt;/td>
&lt;td>Debezium Server / Confluent Connectors&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Apache Flink&lt;/strong>&lt;/td>
&lt;td>Apache 2.0&lt;/td>
&lt;td>ASF&lt;/td>
&lt;td>Ververica Platform, Aiven&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>HF Transformers&lt;/strong>&lt;/td>
&lt;td>Apache 2.0&lt;/td>
&lt;td>Hugging Face SAS&lt;/td>
&lt;td>HF Inference Endpoints / Enterprise Hub&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>PEFT&lt;/strong>&lt;/td>
&lt;td>Apache 2.0&lt;/td>
&lt;td>Hugging Face SAS&lt;/td>
&lt;td>— (part of the HF offering)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>bitsandbytes&lt;/strong>&lt;/td>
&lt;td>MIT&lt;/td>
&lt;td>Tim Dettmers + community&lt;/td>
&lt;td>—&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>MLflow&lt;/strong>&lt;/td>
&lt;td>Apache 2.0&lt;/td>
&lt;td>LF AI &amp;amp; Data&lt;/td>
&lt;td>Databricks MLflow&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Axolotl&lt;/strong>&lt;/td>
&lt;td>Apache 2.0&lt;/td>
&lt;td>OpenAccess AI Collective&lt;/td>
&lt;td>—&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Ray (Train)&lt;/strong>&lt;/td>
&lt;td>Apache 2.0&lt;/td>
&lt;td>Anyscale + community&lt;/td>
&lt;td>Anyscale Platform&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>DeepSpeed&lt;/strong>&lt;/td>
&lt;td>MIT&lt;/td>
&lt;td>Microsoft&lt;/td>
&lt;td>—&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>DeepEval&lt;/strong>&lt;/td>
&lt;td>Apache 2.0&lt;/td>
&lt;td>Confident AI&lt;/td>
&lt;td>Confident AI SaaS&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>RAGAS&lt;/strong>&lt;/td>
&lt;td>Apache 2.0&lt;/td>
&lt;td>Exploding Gradients&lt;/td>
&lt;td>—&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Promptfoo&lt;/strong>&lt;/td>
&lt;td>MIT&lt;/td>
&lt;td>Promptfoo, Inc.&lt;/td>
&lt;td>Promptfoo Cloud&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>NeMo Guardrails&lt;/strong>&lt;/td>
&lt;td>Apache 2.0&lt;/td>
&lt;td>NVIDIA&lt;/td>
&lt;td>NeMo Microservices&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Presidio&lt;/strong>&lt;/td>
&lt;td>MIT&lt;/td>
&lt;td>Microsoft&lt;/td>
&lt;td>—&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Phoenix (Arize)&lt;/strong>&lt;/td>
&lt;td>Elastic v2&lt;/td>
&lt;td>Arize AI&lt;/td>
&lt;td>Arize Platform&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>vLLM&lt;/strong>&lt;/td>
&lt;td>Apache 2.0&lt;/td>
&lt;td>vLLM Project / LF AI &amp;amp; Data&lt;/td>
&lt;td>several (Red Hat, AWS, IBM, NVIDIA)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>TGI&lt;/strong>&lt;/td>
&lt;td>Apache 2.0&lt;/td>
&lt;td>Hugging Face SAS&lt;/td>
&lt;td>HF Inference Endpoints&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>SGLang&lt;/strong>&lt;/td>
&lt;td>Apache 2.0&lt;/td>
&lt;td>LMSys + community&lt;/td>
&lt;td>—&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>TensorRT-LLM&lt;/strong>&lt;/td>
&lt;td>Apache 2.0&lt;/td>
&lt;td>NVIDIA&lt;/td>
&lt;td>NVIDIA AI Enterprise&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>llama.cpp&lt;/strong>&lt;/td>
&lt;td>MIT&lt;/td>
&lt;td>Georgi Gerganov + community&lt;/td>
&lt;td>—&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Triton Inference Server&lt;/strong>&lt;/td>
&lt;td>BSD-3&lt;/td>
&lt;td>NVIDIA&lt;/td>
&lt;td>NVIDIA AI Enterprise&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>KServe&lt;/strong>&lt;/td>
&lt;td>Apache 2.0&lt;/td>
&lt;td>LF AI &amp;amp; Data (Kubeflow)&lt;/td>
&lt;td>—&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Envoy AI Gateway&lt;/strong>&lt;/td>
&lt;td>Apache 2.0&lt;/td>
&lt;td>CNCF / Tetrate&lt;/td>
&lt;td>Tetrate Service Bridge&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>LiteLLM&lt;/strong>&lt;/td>
&lt;td>MIT&lt;/td>
&lt;td>BerriAI&lt;/td>
&lt;td>LiteLLM Cloud&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>OpenTelemetry&lt;/strong>&lt;/td>
&lt;td>Apache 2.0&lt;/td>
&lt;td>CNCF&lt;/td>
&lt;td>several vendors (Honeycomb, Datadog, Grafana)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Tempo&lt;/strong>&lt;/td>
&lt;td>AGPL 3.0&lt;/td>
&lt;td>Grafana Labs&lt;/td>
&lt;td>Grafana Cloud Tempo&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Jaeger&lt;/strong>&lt;/td>
&lt;td>Apache 2.0&lt;/td>
&lt;td>CNCF&lt;/td>
&lt;td>—&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Prometheus&lt;/strong>&lt;/td>
&lt;td>Apache 2.0&lt;/td>
&lt;td>CNCF&lt;/td>
&lt;td>Grafana Cloud, AMP, GCP Managed Prom, Azure&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Grafana&lt;/strong>&lt;/td>
&lt;td>AGPL 3.0&lt;/td>
&lt;td>Grafana Labs&lt;/td>
&lt;td>Grafana Cloud, Grafana Enterprise&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Loki&lt;/strong>&lt;/td>
&lt;td>AGPL 3.0&lt;/td>
&lt;td>Grafana Labs&lt;/td>
&lt;td>Grafana Cloud Loki&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Langfuse&lt;/strong>&lt;/td>
&lt;td>MIT (core) / EE&lt;/td>
&lt;td>Langfuse GmbH&lt;/td>
&lt;td>Langfuse Cloud&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Tetragon&lt;/strong>&lt;/td>
&lt;td>Apache 2.0&lt;/td>
&lt;td>Cilium / CNCF / Isovalent&lt;/td>
&lt;td>Isovalent Enterprise&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Hubble&lt;/strong>&lt;/td>
&lt;td>Apache 2.0&lt;/td>
&lt;td>Cilium / CNCF&lt;/td>
&lt;td>Isovalent Enterprise&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Evidently AI&lt;/strong>&lt;/td>
&lt;td>Apache 2.0&lt;/td>
&lt;td>Evidently AI&lt;/td>
&lt;td>Evidently Cloud&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Apache Airflow&lt;/strong>&lt;/td>
&lt;td>Apache 2.0&lt;/td>
&lt;td>ASF&lt;/td>
&lt;td>Astronomer, MWAA, Cloud Composer&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Argo Workflows&lt;/strong>&lt;/td>
&lt;td>Apache 2.0&lt;/td>
&lt;td>CNCF&lt;/td>
&lt;td>—&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Kubeflow Pipelines&lt;/strong>&lt;/td>
&lt;td>Apache 2.0&lt;/td>
&lt;td>CNCF / LF AI &amp;amp; Data&lt;/td>
&lt;td>—&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Feast&lt;/strong>&lt;/td>
&lt;td>Apache 2.0&lt;/td>
&lt;td>LF AI &amp;amp; Data&lt;/td>
&lt;td>Tecton (commercial)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Argilla&lt;/strong>&lt;/td>
&lt;td>Apache 2.0&lt;/td>
&lt;td>Hugging Face&lt;/td>
&lt;td>HF Hub features&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>OpenLineage&lt;/strong>&lt;/td>
&lt;td>Apache 2.0&lt;/td>
&lt;td>LF AI &amp;amp; Data&lt;/td>
&lt;td>—&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>DataHub&lt;/strong>&lt;/td>
&lt;td>Apache 2.0&lt;/td>
&lt;td>Acryl Data&lt;/td>
&lt;td>Acryl Cloud&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>&lt;strong>The pattern to watch for when reading the table:&lt;/strong> &lt;strong>AGPL 3.0&lt;/strong> and &lt;strong>Elastic v2&lt;/strong> are the ones that add the most friction in companies with strict licensing policies (legal asks for a specific review). The &lt;strong>Apache 2.0&lt;/strong> ones pass compliance without argument. Those with an &amp;ldquo;EE Enterprise&amp;rdquo; tier or equivalent hide a decision: the OSS version is functionally complete for production, but team features (SSO, audit, advanced RBAC) live in the commercial version. For ENS customers under a HIGH declaration, the EE features (SSO with corporate SAML/OIDC, immutable audit logs) are usually mandatory, so it is worth knowing the price beforehand.&lt;/p>
&lt;h2 id="when-to-move-from-the-minimum-stack-to-the-full-stack">When to move from the &amp;ldquo;minimum stack&amp;rdquo; to the &amp;ldquo;full stack&amp;rdquo;&lt;/h2>
&lt;p>The whole catalogue can be intimidating. But you do not build all of it on day one. There is a reasonable order that this blog has been validating in posts throughout the series. The &lt;strong>minimum viable stack&lt;/strong> that serves an LLM API with acceptable discipline:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Serving&lt;/strong>: vLLM on Kubernetes plus an Envoy AI Gateway in front.&lt;/li>
&lt;li>&lt;strong>Data&lt;/strong>: Postgres + pgvector (no Qdrant), MinIO for object store, no Kafka.&lt;/li>
&lt;li>&lt;strong>Tune&lt;/strong>: Axolotl + MLflow, no Ray Train.&lt;/li>
&lt;li>&lt;strong>Eval&lt;/strong>: Promptfoo in CI, no RAGAS and no judge in production.&lt;/li>
&lt;li>&lt;strong>Observe&lt;/strong>: OTel Collector + Prometheus + Grafana + Langfuse, no Phoenix and no Tetragon.&lt;/li>
&lt;li>&lt;strong>Retrain&lt;/strong>: feedback in Postgres + crontab scripts, no Airflow.&lt;/li>
&lt;li>&lt;strong>Versioning&lt;/strong>: prompts in Langfuse + datasets in DVC over MinIO, no lakeFS.&lt;/li>
&lt;/ul>
&lt;p>That is &lt;strong>about 8-10 components&lt;/strong> and it serves a reasonable LLM system for a single tenant with moderate traffic. As the system grows, there are identifiable moments where adding each piece pays off:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Trigger&lt;/th>
&lt;th>Component to add&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Multi-tenant with isolated corpora&lt;/td>
&lt;td>&lt;strong>Qdrant&lt;/strong> (collections per tenant, ACL)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>The corpus is refreshed often and periodically breaks&lt;/td>
&lt;td>&lt;strong>lakeFS&lt;/strong> (branches with pre-merge hooks)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>The embedding pipeline needs streaming&lt;/td>
&lt;td>&lt;strong>Kafka + Debezium + Flink&lt;/strong>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Retraining moves from monthly to weekly&lt;/td>
&lt;td>&lt;strong>Airflow&lt;/strong> or &lt;strong>Argo Workflows&lt;/strong>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Shared features appear (customer profile, scoring)&lt;/td>
&lt;td>&lt;strong>Feast&lt;/strong>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Annotation outgrows informal capacity&lt;/td>
&lt;td>&lt;strong>Argilla&lt;/strong>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>RAG eval needs specific metrics&lt;/td>
&lt;td>&lt;strong>RAGAS&lt;/strong> + Langfuse datasets&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>ENS compliance demands runtime audit&lt;/td>
&lt;td>&lt;strong>Tetragon + Hubble&lt;/strong>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Drift is invisible and shows up late&lt;/td>
&lt;td>&lt;strong>Evidently&lt;/strong>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>A single stack stops covering multi-model&lt;/td>
&lt;td>&lt;strong>Triton&lt;/strong> or &lt;strong>KServe&lt;/strong> with several predictors&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Multiple simultaneous multi-tenant adapters&lt;/td>
&lt;td>&lt;strong>vLLM Production Stack&lt;/strong> + a dedicated Operator&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>Each jump adds 1-2 components and is worth the cost only when the trigger is clear. Adding Kafka &amp;ldquo;just in case&amp;rdquo; when the corpus is updated once a month is net negative work.&lt;/p>
&lt;h2 id="what-we-have-not-covered-yet">What we have not covered (yet)&lt;/h2>
&lt;p>Some pieces still deserve a post of their own:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Schema Registry&lt;/strong> for LLM data and prompts (Confluent OSS, Karapace, JSON Schema Registry).&lt;/li>
&lt;li>&lt;strong>Catalogue + lineage&lt;/strong> in depth: DataHub vs Atlas vs OpenMetadata + OpenLineage taken seriously.&lt;/li>
&lt;li>&lt;strong>Federated learning&lt;/strong> over OSS (Flower, FedML) for scenarios where the data is not centralised.&lt;/li>
&lt;li>&lt;strong>OSS MCP Servers&lt;/strong> and their place in the stack as a tools / actions layer.&lt;/li>
&lt;li>&lt;strong>&amp;ldquo;Agentic&amp;rdquo; evals&lt;/strong> specific to multi-step systems with tool use.&lt;/li>
&lt;li>&lt;strong>Upgrade best practices&lt;/strong> for each component (vLLM every 6 weeks, Kafka major every 18 months, etc.).&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/anatomy-llm-request-production-tour-six-stages/">Anatomy of an LLM request in production&lt;/a> — the forensic piece that follows one request through the six stages; this catalogue is the list of tools that appeared along that route.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/parallel-catalogue-llmops-stages-open-source-hyperscalers/">The parallel catalogue: OSS vs hyperscalers&lt;/a> — the horizontal cut that shows, for each stage, what each OSS tool does and what its equivalent is on AWS, GCP and Azure.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&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 of the pipeline to which this catalogue puts concrete OSS names.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/mlops-for-llms-landscape-2026/">MLOps specific to LLMs in 2026&lt;/a> — general context on LLMOps.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/data-versioning-llmops-dvc-lakefs-reproducible-golden-dataset/">Data versioning with DVC and lakeFS&lt;/a> — the deep-dive on the two OSS protagonists of the Data stage + cross-cutting piece.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/prompt-versioning-contract-five-word-change/">Prompt versioning with Langfuse and MLflow&lt;/a> — the deep-dive on the Prompt cross-cutting piece.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/continuous-fine-tuning-production-real-traffic-deployed-adapter/">Continuous fine-tuning in production&lt;/a> — the Tune stage in real operations.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/evals-llm-layer-after-tracing/">Evals: the layer after tracing&lt;/a> and &lt;a href="https://blog.lo0.es/en/posts/llm-guardrails-safety-four-lines-of-defence/">Guardrails and safety in LLMs&lt;/a> — the deep-dives on Eval + safety.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/llm-guard-anatomy-scanners-langfuse-vllm-litellm/">LLM Guard: the sworn translator with a notebook of equivalences&lt;/a> — a specific zoom on the Protect AI tool with its Anonymize + Vault + Deanonymize pattern, the 36 composable scanners and the four deployment modes (lib, FastAPI API, OTel sidecar, AI Gateway plugin).&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&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/pagedattention-vllm-block-manager/">PagedAttention&lt;/a> · &lt;a href="https://blog.lo0.es/en/posts/disaggregated-serving-prefill-decode-specialised-pods/">Disaggregated serving&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/llm-inference-operators-kubernetes/">LLM K8s operators&lt;/a> · &lt;a href="https://blog.lo0.es/en/posts/gpu-cluster-as-multi-tenant-platform/">Multi-tenant GPU cluster&lt;/a> — Deploy in all its layers.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/agentsight-llm-tracing-ebpf-zero-instrumentation/">AgentSight LLM tracing&lt;/a> · &lt;a href="https://blog.lo0.es/en/posts/mcp-deep-observability-opentelemetry/">MCP observability with OTel&lt;/a> · &lt;a href="https://blog.lo0.es/en/posts/ebpf-local-inference-statistical-drift-detection/">eBPF + drift&lt;/a> — Observe from its three angles.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/retrain-closing-the-loop-feedback-dataset-adapter/">Retrain: closing the loop&lt;/a> — the Retrain stage in detail.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/langfuse-inside-sorting-centre-bottleneck/">Langfuse from the inside: v3 architecture and the 10 backend knobs&lt;/a> — the operational deep-dive on the star observability tool of this catalogue: a six-service architecture, asynchronous ingestion and self-hosted tuning on the example cluster.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/isolating-ai-agents-workstation-to-cluster/">Isolating AI agents: overview&lt;/a> and &lt;a href="https://blog.lo0.es/en/posts/runbook-caging-ai-agent-bubblewrap-tetragon/">runbook&lt;/a> — the runtime security layer for agents that execute code: bubblewrap/ai-jail on the client and Tetragon (eBPF) on the cluster, with its Kata &lt;code>RuntimeClass&lt;/code>.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/kserve-open-inference-protocol-control-plane/">Model chain of trust (1/4): KServe and the Open Inference Protocol&lt;/a> — the deep-dive on the control plane of the Deploy stage: InferenceService, ServingRuntime and the API contract that this catalogue only enumerates.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/model-chain-of-trust-where-the-bytes-live-registry-oci-artefacts/">Model chain of trust (2/4): registry, OCI artefacts and distribution&lt;/a> — the missing piece between Tune and Deploy: where the weights live (OCI artefacts with ORAS, Harbor, MLflow) and how they reach the GPU node.&lt;/p>
&lt;/li>
&lt;/ul>
&lt;h2 id="references">References&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://docs.vllm.ai/">vLLM&lt;/a> · &lt;a href="https://github.com/vllm-project/production-stack">vLLM Production Stack&lt;/a> · &lt;a href="https://huggingface.co/docs/text-generation-inference/">TGI&lt;/a> · &lt;a href="https://docs.sglang.ai/">SGLang&lt;/a> · &lt;a href="https://nvidia.github.io/TensorRT-LLM/">TensorRT-LLM&lt;/a> · &lt;a href="https://github.com/ggerganov/llama.cpp">llama.cpp&lt;/a> — OSS inference engines.&lt;/li>
&lt;li>&lt;a href="https://docs.nvidia.com/deeplearning/triton-inference-server/">Triton Inference Server&lt;/a> · &lt;a href="https://kserve.github.io/website/">KServe&lt;/a> · &lt;a href="https://aigateway.envoyproxy.io/">Envoy AI Gateway&lt;/a> · &lt;a href="https://docs.litellm.ai/">LiteLLM&lt;/a> — orchestration and AI gateway.&lt;/li>
&lt;li>&lt;a href="https://qdrant.tech/documentation/">Qdrant&lt;/a> · &lt;a href="https://github.com/pgvector/pgvector">pgvector&lt;/a> · &lt;a href="https://milvus.io/docs">Milvus&lt;/a> — vector databases.&lt;/li>
&lt;li>&lt;a href="https://dvc.org/doc">DVC&lt;/a> · &lt;a href="https://docs.lakefs.io/">lakeFS&lt;/a> · &lt;a href="https://min.io/docs/">MinIO&lt;/a> — versioning and object store.&lt;/li>
&lt;li>&lt;a href="https://kafka.apache.org/documentation/">Apache Kafka&lt;/a> · &lt;a href="https://debezium.io/documentation/">Debezium&lt;/a> · &lt;a href="https://flink.apache.org/">Apache Flink&lt;/a> — streams and CDC.&lt;/li>
&lt;li>&lt;a href="https://huggingface.co/docs/transformers">Hugging Face Transformers&lt;/a> · &lt;a href="https://huggingface.co/docs/peft">PEFT&lt;/a> · &lt;a href="https://huggingface.co/docs/bitsandbytes">bitsandbytes&lt;/a> · &lt;a href="https://docs.axolotl.ai/">Axolotl&lt;/a> — fine-tuning.&lt;/li>
&lt;li>&lt;a href="https://mlflow.org/docs/">MLflow&lt;/a> · &lt;a href="https://docs.ray.io/en/latest/train/">Ray Train&lt;/a> · &lt;a href="https://www.kubeflow.org/docs/components/trainer/">Kubeflow Training Operator&lt;/a> — training orchestration.&lt;/li>
&lt;li>&lt;a href="https://docs.confident-ai.com/">DeepEval&lt;/a> · &lt;a href="https://docs.ragas.io/">RAGAS&lt;/a> · &lt;a href="https://promptfoo.dev/docs/">Promptfoo&lt;/a> · &lt;a href="https://docs.nvidia.com/nemo/guardrails/">NeMo Guardrails&lt;/a> · &lt;a href="https://microsoft.github.io/presidio/">Presidio&lt;/a> — evals and guardrails.&lt;/li>
&lt;li>&lt;a href="https://opentelemetry.io/docs/">OpenTelemetry&lt;/a> · &lt;a href="https://opentelemetry.io/docs/specs/semconv/gen-ai/">OTel GenAI semconv&lt;/a> · &lt;a href="https://grafana.com/docs/tempo/">Tempo&lt;/a> · &lt;a href="https://prometheus.io/docs/">Prometheus&lt;/a> · &lt;a href="https://grafana.com/docs/">Grafana&lt;/a> · &lt;a href="https://grafana.com/docs/loki/">Loki&lt;/a> — observability foundation.&lt;/li>
&lt;li>&lt;a href="https://langfuse.com/docs">Langfuse&lt;/a> · &lt;a href="https://docs.arize.com/phoenix">Phoenix Arize&lt;/a> · &lt;a href="https://docs.evidentlyai.com/">Evidently AI&lt;/a> — LLM observability and drift.&lt;/li>
&lt;li>&lt;a href="https://docs.cilium.io/">Cilium&lt;/a> · &lt;a href="https://tetragon.io/">Tetragon&lt;/a> · &lt;a href="https://docs.cilium.io/en/stable/observability/hubble/">Hubble&lt;/a> — eBPF runtime.&lt;/li>
&lt;li>&lt;a href="https://airflow.apache.org/docs/">Apache Airflow&lt;/a> · &lt;a href="https://argo-workflows.readthedocs.io/">Argo Workflows&lt;/a> · &lt;a href="https://www.kubeflow.org/docs/components/pipelines/">Kubeflow Pipelines&lt;/a> · &lt;a href="https://docs.feast.dev/">Feast&lt;/a> · &lt;a href="https://docs.argilla.io/">Argilla&lt;/a> — orchestration + retrain + annotation.&lt;/li>
&lt;/ul></description></item><item><title>Retrain: closing the loop between the production incident and the adapter that fixes it</title><link>https://blog.lo0.es/en/posts/retrain-closing-the-loop-feedback-dataset-adapter/</link><pubDate>Fri, 22 May 2026 07:45:00 +0200</pubDate><guid>https://blog.lo0.es/en/posts/retrain-closing-the-loop-feedback-dataset-adapter/</guid><description>&lt;h2 id="tldr">TL;DR&lt;/h2>
&lt;p>The &lt;strong>Retrain&lt;/strong> stage of the &lt;a href="https://blog.lo0.es/en/posts/six-stage-llmops-pipeline-architecture-and-deep-dive/">six-stage LLMOps pipeline&lt;/a> is the one that closes the cycle. Without it, the deployed system is a project that ends; with it, it is a living practice that improves every quarter. The mechanics at the top level fit into five sequential sub-processes: capture feedback (explicit via thumbs + implicit via latency, abandonment, retries), triage incidents by root cause (model issue, retrieval issue, prompt issue, infra issue), enrich the dataset with the cases where the system failed plus the correct answer labelled by a human, decide the cadence (quarterly scheduled by default + incident-driven when a pattern crosses a threshold), and promote the candidate by taking it through Tune → Eval → Deploy with gates against the model in production. The tools the market has consolidated on in 2026: Langfuse for feedback collection in the UI, Argilla and Label Studio for human annotation of the enriched dataset, MLflow stages for promotion. The most lethal trap, and the most common, is the &lt;strong>open loop&lt;/strong>: having all the pieces but no structured channel connecting them, so the Retrain stage boils down to &amp;ldquo;we will retrain when we need to&amp;rdquo; and therefore never.&lt;/p>
&lt;h2 id="you-are-here-retrain-closes-the-cycle-back-to-data">You are here: Retrain (closes the cycle back to Data)&lt;/h2>
&lt;p>This post goes into the detail of stage 6 of the LLMOps pipeline. What follows takes apart the five Retrain sub-processes at complete top level, without descending into the internal mechanics of Tune (covered in the &lt;a href="https://blog.lo0.es/en/posts/continuous-fine-tuning-production-real-traffic-deployed-adapter/">continuous fine-tuning post&lt;/a>) or into the implementation of the eval suites (covered in the &lt;a href="https://blog.lo0.es/en/posts/evals-llm-layer-after-tracing/">evals post&lt;/a>).&lt;/p>
&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: Retrain">
&lt;style>.box{stroke:#444;stroke-width:1.4;rx:6}.active{fill:#ffd24a;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(#rtm)}.cyc{stroke:#c66;stroke-width:2;fill:none;stroke-dasharray:4 2;marker-end:url(#rtm)}&lt;/style>
&lt;defs>&lt;marker id="rtm" 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="390" y="20" text-anchor="middle" class="lbl">You are here: RETRAIN · closes the cycle from Observe to Data&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 idle"/>&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 active"/>&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-master-analogy-the-hospital-mortality-committee">The master analogy: the hospital mortality committee&lt;/h2>
&lt;p>A serious hospital holds periodic &lt;strong>morbidity &amp;amp; mortality&lt;/strong> (M&amp;amp;M) meetings: the doctors review, without blame but without omitting anything, the cases where a patient died or had a serious complication. They look for root cause, identify patterns, adjust protocols, and leave a record. The committee does not meet when someone &amp;ldquo;remembers&amp;rdquo;; it is scheduled and mandatory. And when there is a catastrophic incident outside the cycle, an extraordinary M&amp;amp;M is convened within 48 h.&lt;/p>
&lt;p>The &lt;strong>Retrain&lt;/strong> stage is exactly that for an LLM system:&lt;/p>
&lt;ul>
&lt;li>The morbidity is the minor incidents: answers the user marked with a thumbs-down, sessions where they retried the same question three times, examples where the eval score dropped but not below the alert threshold.&lt;/li>
&lt;li>The mortality is the serious incidents: the system gave a dangerous answer, a key customer cancelled over a string of errors, the agent executed a tool it should not have.&lt;/li>
&lt;li>The periodic meetings are the quarterly &lt;strong>scheduled retrain&lt;/strong>: you look at the accumulated feedback, prioritise, and decide what goes into the enriched dataset for the next training run.&lt;/li>
&lt;li>The extraordinary M&amp;amp;Ms are the &lt;strong>incident-driven retrains&lt;/strong>: faced with a problematic pattern that crosses a threshold, a mini-cycle is fired off outside the cadence.&lt;/li>
&lt;/ul>
&lt;p>Without this discipline, incidents are anecdotes that get forgotten and the system does not learn.&lt;/p>
&lt;h2 id="sub-process-1--feedback-capture">Sub-process 1 — Feedback capture&lt;/h2>
&lt;p>The first link in the loop is observing what the system does badly. There are two families of feedback, and they complement each other.&lt;/p>
&lt;h3 id="explicit-feedback">Explicit feedback&lt;/h3>
&lt;p>The user tells you directly that the answer was bad. Mechanisms:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Thumbs up/down&lt;/strong> in the UI: the classic, low latency (1 click). Coverage: typically 1-5 % of traffic. Bias: users vote more when they are annoyed than when they are happy.&lt;/li>
&lt;li>&lt;strong>Annotation by power users&lt;/strong>: internal customers or experts who leave structured comments (&amp;ldquo;the answer is correct but the format does not follow our style guide&amp;rdquo;). Far lower coverage but high quality.&lt;/li>
&lt;li>&lt;strong>&amp;ldquo;What went wrong?&amp;rdquo; forms&lt;/strong> when the thumbs-down is clicked: predefined options (hallucination, format, tone, incomplete, off-topic) + optional free text. Enables automated triaging.&lt;/li>
&lt;li>&lt;strong>Re-editing&lt;/strong>: if the system writes a draft (an email, some code) and the user edits it before sending, that edit is rich feedback. The diff between what was generated and what was sent is an explicit signal of the failure.&lt;/li>
&lt;/ul>
&lt;p>All explicit feedback travels labelled with &lt;code>trace_id&lt;/code>, &lt;code>prompt_version&lt;/code>, &lt;code>model&lt;/code>, &lt;code>user_id&lt;/code> (anonymised where required), &lt;code>timestamp&lt;/code>, and enters the feedback store. Langfuse, Phoenix and LangSmith have built-in UI for this; what matters is that &lt;strong>every thumbs-down materialises as a row in a table&lt;/strong>, not as an event that gets lost.&lt;/p>
&lt;h3 id="implicit-feedback">Implicit feedback&lt;/h3>
&lt;p>The user tells you nothing but their behaviour gives the problem away. Typical signals:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Anomalous latency&lt;/strong>: the system&amp;rsquo;s TTFT was 8 s when the average is 800 ms. Indicates overload, heavy retrieval, an unexpectedly large prefill. Covered at top level in &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/ebpf-local-inference-statistical-drift-detection/">ebpf+drift&lt;/a>.&lt;/li>
&lt;li>&lt;strong>Abandonment rate&lt;/strong>: the user leaves the session before reading the full answer. If the ratio goes from 5 % to 15 % in one segment, something is wrong.&lt;/li>
&lt;li>&lt;strong>User retries&lt;/strong>: the user asks the same question (or a very similar one) 2-3 times. Indicates the first answer was no use to them.&lt;/li>
&lt;li>&lt;strong>Aborted sessions&lt;/strong>: the user closes the chat before the model finishes generating. In streaming, a high abort ratio is a strong indicator.&lt;/li>
&lt;li>&lt;strong>Workflow exit&lt;/strong>: in an agent, the user cancels the plan before execution. The agent&amp;rsquo;s trajectory did not convince them.&lt;/li>
&lt;li>&lt;strong>Statistical drift&lt;/strong> in the distribution of inputs or outputs (KS test, PSI, embedding-space shift). Covered at top level in &lt;a href="https://blog.lo0.es/en/posts/ebpf-local-inference-statistical-drift-detection/">eBPF + drift&lt;/a>.&lt;/li>
&lt;/ul>
&lt;p>Implicit signals are noisier but cover 100 % of traffic, not the 1-5 % of explicit feedback. Combining them with explicit feedback gives the full picture.&lt;/p>
&lt;h3 id="typical-storage-pattern">Typical storage pattern&lt;/h3>
&lt;p>All the feedback, explicit and implicit, ends up in a common table with a minimal schema:&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">CREATE&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">feedback_signals&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="n">signal_id&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">UUID&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">PRIMARY&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">KEY&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="n">trace_id&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">UUID&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">NOT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">NULL&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="n">request_id&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">UUID&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">NOT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">NULL&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="n">signal_type&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nb">VARCHAR&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">NOT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">NULL&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c1">-- &amp;#39;thumbs&amp;#39;, &amp;#39;retry&amp;#39;, &amp;#39;abandon&amp;#39;, &amp;#39;drift&amp;#39;, ...
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">signal_value&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">JSONB&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c1">-- feedback payload (thumbs-down text, latency, etc.)
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">prompt_id&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nb">VARCHAR&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="n">prompt_version&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nb">INT&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="n">model&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nb">VARCHAR&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="n">user_segment&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nb">VARCHAR&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c1">-- tenant, plan, geo
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">occurred_at&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">TIMESTAMPTZ&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">NOT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">NULL&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="n">triaged&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nb">BOOLEAN&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">DEFAULT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">FALSE&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="n">triage_label&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nb">VARCHAR&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c1">-- filled in by sub-process 2
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1">&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>Postgres is more than enough for reasonable volumes (millions of rows a month). Langfuse uses Postgres underneath. For high volumes you can divert to ClickHouse or BigQuery, but it is rarely worth the extra complexity.&lt;/p>
&lt;h2 id="sub-process-2--triage-by-root-cause">Sub-process 2 — Triage by root cause&lt;/h2>
&lt;p>Having feedback is not enough. You have to categorise each incident by its root cause before deciding what to do with it. Without triage, the enriched dataset is a junk drawer and the next retrain fixes nothing in particular.&lt;/p>
&lt;p>The four canonical categories:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Category&lt;/th>
&lt;th>Means&lt;/th>
&lt;th>Typical action&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>&lt;strong>Model issue&lt;/strong>&lt;/td>
&lt;td>The model answered badly to something that was within its theoretical capability.&lt;/td>
&lt;td>Candidate case for the enriched dataset for the next Tune.&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Retrieval issue&lt;/strong>&lt;/td>
&lt;td>The RAG did not retrieve the right context. The model answered reasonably from poor context.&lt;/td>
&lt;td>Adjust reranker, chunking, indexing — the Data stage, not Tune.&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Prompt issue&lt;/strong>&lt;/td>
&lt;td>The system prompt does not cover the case, or covers it badly.&lt;/td>
&lt;td>New version of the prompt (the cross-cutting stage of &lt;a href="https://blog.lo0.es/en/posts/prompt-versioning-contract-five-word-change/">prompt versioning&lt;/a>).&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Infra issue&lt;/strong>&lt;/td>
&lt;td>Latency, timeout, 5xx error, overload.&lt;/td>
&lt;td>Adjust capacity / autoscaler — the Deploy stage.&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>Triage can be done:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Manually&lt;/strong>: a human (typically: the MLE team / data scientist) reviews the feedback in the Langfuse / Phoenix / LangSmith UI, looks at the complete trace, labels it. Cost: 2-5 min per incident. Sustainable up to around 50-100 incidents/week per person.&lt;/li>
&lt;li>&lt;strong>Assisted by LLM-as-classifier&lt;/strong>: an LLM classifies the incident into one of the four categories with a structured prompt. 80-90 % coverage automated, the rest escalated to a human. State of the art in 2026: GPT-5, Claude 4, Llama 3 70B-instruct with a careful prompt give F1 &amp;gt; 0.85 against calibrated internal rubrics.&lt;/li>
&lt;li>&lt;strong>Heuristic rules for the obvious ones&lt;/strong>: a 5xx error is always infra; latency &amp;gt; 5σ is always infra; a thumbs-down over RAG with &lt;code>context_relevance &amp;lt; 0.3&lt;/code> is retrieval. Captures 30-50 % of the volume at zero cost.&lt;/li>
&lt;/ul>
&lt;p>The productive pattern is: &lt;strong>rules → LLM classifier → human&lt;/strong>, cascaded, escalating only what the previous level does not resolve with confidence.&lt;/p>
&lt;pre tabindex="0">&lt;code>New feedback
│
▼
[heuristic rules]
│
├── high confidence → automatic label
│
▼ (rest)
[LLM-as-classifier]
│
├── high confidence → suggested label
│
▼ (rest, or disagreement with the rules)
[human review]
│
└── final label → feedback_signals.triage_label
&lt;/code>&lt;/pre>&lt;h2 id="sub-process-3--dataset-enrichment">Sub-process 3 — Dataset enrichment&lt;/h2>
&lt;p>Once the incidents are triaged with a &lt;code>model issue&lt;/code> label, those cases are candidates to enter the enriched dataset that will feed the next Tune. But they do not go in as they are: you need &lt;strong>the correct answer labelled by a human&lt;/strong>.&lt;/p>
&lt;h3 id="how-an-enriched-case-is-built">How an enriched case is built&lt;/h3>
&lt;p>Each enriched case is a minimal tuple:&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">case_id&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">enrich-2026-05-22-0142&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">source_trace_id&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">trace-xyz&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">prompt_input&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">system&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;You are a support assistant...&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&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;I cancelled my order on Tuesday but I can still see the charge&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">prompt_version_at_failure&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">customer_support_v3@v2&lt;/span>&lt;span class="w">
&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_at_failure&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">llama-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">failure_response&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;Sorry for the inconvenience. The charge should be reversed within 5-7 business days.&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">human_corrected_response&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;Sorry for the inconvenience. I have checked your account and I can see the refund was processed on Wednesday. It will show in your account within a further 24-48 h depending on your bank. Here is the refund ID: ABC123.&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">labeler&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;support_agent_M3&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">labeled_at&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;2026-05-22T09:30:00Z&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">quality_score&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 class="c"># 1-5, eval by a second human before promoting to the dataset&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">notes&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;The model gave a generic answer without checking the real state of the refund. It needs the account_lookup tool.&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>What matters is that the enriched case has enough context to be reproduced: original prompt, prompt version, model, failed answer, correct answer. Without this, the case is a loose data point, useless for training.&lt;/p>
&lt;h3 id="annotation-tools">Annotation tools&lt;/h3>
&lt;p>Three dominant options in 2026:&lt;/p>
&lt;p>&lt;a href="https://argilla.io/">Argilla&lt;/a> (OSS, maintained by Hugging Face since 2024). Designed specifically for LLM datasets: annotation of (input, output) pairs, preference data (DPO/RLHF), instruction tuning. Python-friendly UI. Native integration with HuggingFace datasets and with MLflow.&lt;/p>
&lt;p>&lt;a href="https://labelstud.io/">Label Studio&lt;/a> (OSS from Heartex). More generalist, also works for LLM. Rich, configurable, multi-modal UI. Better when the team already uses it for other tasks.&lt;/p>
&lt;p>Langfuse built-in UI. Lets you annotate existing traces directly with thumbs + text + categorical labels. Useful for lightweight feedback; for building serious preference or instruction tuning datasets, Argilla and Label Studio are a better fit.&lt;/p>
&lt;p>Typical pattern: Langfuse for traffic feedback + Argilla to build the formal enriched dataset that goes into the Tune pipeline. The traces marked as candidates in Langfuse are exported periodically to Argilla, where a human produces the correct answer and validates quality.&lt;/p>
&lt;h3 id="quality-validation-before-promoting">Quality validation before promoting&lt;/h3>
&lt;p>Not every annotated case enters the dataset. Good discipline requires:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Double annotation&lt;/strong> on at least 10-20 % of the critical cases (two independent annotators; if they disagree, a third resolves it).&lt;/li>
&lt;li>&lt;strong>Quality score&lt;/strong> per case (1-5 or equivalent): only cases with a score ≥ 4 enter the dataset.&lt;/li>
&lt;li>&lt;strong>Dataset versioning&lt;/strong> with &lt;a href="https://blog.lo0.es/en/posts/data-versioning-llmops-dvc-lakefs-reproducible-golden-dataset/">DVC + lakeFS&lt;/a> or equivalent, just like the rest of the Data stage datasets.&lt;/li>
&lt;li>&lt;strong>Reserved holdout&lt;/strong>: a portion of the enriched dataset is set aside to evaluate the retrained adapter, without it entering training. If the dataset is enriched with cases where the model failed and the same dataset is used to evaluate, you are measuring memorisation, not learning.&lt;/li>
&lt;/ul>
&lt;h2 id="sub-process-4--cadences-scheduled-vs-incident-driven">Sub-process 4 — Cadences: scheduled vs incident-driven&lt;/h2>
&lt;p>Once enriched dataset accumulates, it remains to decide when the retrain is launched. There are two complementary cadences.&lt;/p>
&lt;h3 id="scheduled-retrain-quarterly-by-default">Scheduled retrain (quarterly by default)&lt;/h3>
&lt;p>A process fixed in the calendar. Every quarter, in a specific week, the team:&lt;/p>
&lt;ol>
&lt;li>Closes the accumulated feedback capture cycle.&lt;/li>
&lt;li>Counts the enriched cases available (typically tens to hundreds per quarter).&lt;/li>
&lt;li>Launches the fine-tuning pipeline with the aggregated dataset (golden dataset + the quarter&amp;rsquo;s enriched cases).&lt;/li>
&lt;li>Evaluates the candidate against the full suite + the enriched holdout.&lt;/li>
&lt;li>Promotes it if it passes the eval gates.&lt;/li>
&lt;/ol>
&lt;p>Advantages: predictable capacity planning, a closed budget, controlled risk, a team that is not burnt out. The default.&lt;/p>
&lt;h3 id="incident-driven-retrain">Incident-driven retrain&lt;/h3>
&lt;p>When a serious incident crosses a threshold, a mini-cycle fires off outside the cadence. Typical triggers:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Drift detected&lt;/strong> in the input/output distribution over threshold (KS p-value &amp;lt; 0.01, PSI &amp;gt; 0.25, embedding-space shift &amp;gt; 2σ).&lt;/li>
&lt;li>&lt;strong>A failing segment&lt;/strong>: a cluster of users or a type of question shows a 3× error rate over baseline for &amp;gt; 48 h.&lt;/li>
&lt;li>&lt;strong>A successful prompt injection or jailbreak attack&lt;/strong> that crosses the severity threshold (covered in &lt;a href="https://blog.lo0.es/en/posts/llm-guardrails-safety-four-lines-of-defence/">guardrails&lt;/a>).&lt;/li>
&lt;li>&lt;strong>External domain change&lt;/strong>: the customer changes policy, a new regulation comes out, and so on. The model trained against the old version stops being valid.&lt;/li>
&lt;/ul>
&lt;p>A typical mini-cycle: feedback from the last 7-14 days, a dataset focused on the problematic segment, fast fine-tuning on top of the existing adapter (not a full retrain), a segment-specific eval gate, canary deployment, promotion if it passes.&lt;/p>
&lt;p>Cost: around 3-7 days of team work depending on severity. &lt;strong>It is not optional for critical cases&lt;/strong>: if the failing segment is regulatory or reputational, the cost of not responding quickly is far higher than that of the mini-cycle.&lt;/p>
&lt;h3 id="anti-pattern-we-will-retrain-when-we-need-to">Anti-pattern: &amp;ldquo;we will retrain when we need to&amp;rdquo;&lt;/h3>
&lt;p>The most lethal phrase in LLMOps. Without explicit scheduling, the scheduled retrain never arrives; without explicit thresholds, the incident-driven one never fires either. The system accumulates silent debt until a catastrophic incident forces a retrain that is already late.&lt;/p>
&lt;p>The minimum discipline: a date in the calendar for the next scheduled run + 3-5 explicit incident-driven thresholds in writing. Without this, the Retrain stage is theatre.&lt;/p>
&lt;h2 id="sub-process-5--promotion-the-candidate-enters-production">Sub-process 5 — Promotion: the candidate enters production&lt;/h2>
&lt;p>Once the candidate adapter exists, it does not go straight into production. It goes through the same flow as any release: Tune → Eval → Deploy with gates.&lt;/p>
&lt;pre tabindex="0">&lt;code>Candidate adapter (from Tune)
│
▼
[full eval suite]
- historical golden dataset
- the quarter&amp;#39;s enriched holdout
- regression vs production
│
passes? → no → block + alert
│
yes
▼
[non-regression eval gate]
- make sure it does not degrade
segments that already worked
│
passes? → no → block + alert
│
yes
▼
[canary deployment]
- 5-10% of traffic to the new
adapter for 24-72 h
- online metrics vs production
│
metrics OK? → no → rollback
│
yes
▼
[full promotion]
- move the label in the model registry
- MLflow stages: Staging → Production
- The previous one goes to Archived (preserves
historical reproducibility)
&lt;/code>&lt;/pre>&lt;p>The registry tools:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>MLflow Model Registry stages&lt;/strong> (Staging, Production, Archived) is the canonical pattern. Promotion is an API call: &lt;code>mlflow.models.transition_stage(name, version, &amp;quot;Production&amp;quot;)&lt;/code>. Audited, revertible.&lt;/li>
&lt;li>&lt;strong>A private Hugging Face Hub&lt;/strong> with a repo per adapter is the &amp;ldquo;Git for models&amp;rdquo; equivalent: versioned by commit hash, branches for staging/production, deploy via PR.&lt;/li>
&lt;li>&lt;strong>vLLM multi-LoRA hot-swap&lt;/strong> (described in &lt;a href="https://blog.lo0.es/en/posts/continuous-fine-tuning-production-real-traffic-deployed-adapter/">continuous fine-tuning&lt;/a>) loads the new adapter without restarting the server, so the physical promotion takes seconds.&lt;/li>
&lt;/ul>
&lt;h2 id="applied-to-typical-on-premise-hardware">Applied to typical on-premise hardware&lt;/h2>
&lt;p>Retrain as a stage does not need big hardware. The calculation:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Feedback collection&lt;/strong>: a small table in Postgres. Trivial on any node.&lt;/li>
&lt;li>&lt;strong>Manual / assisted triage&lt;/strong>: the LLM-as-classifier runs on the same inference engine that serves production, during low-demand hours, at spot priority. Tens of thousands of incidents a month consume on the order of minutes of GPU per day.&lt;/li>
&lt;li>&lt;strong>Dataset enrichment&lt;/strong>: human annotation, no GPU cost. Negligible storage.&lt;/li>
&lt;li>&lt;strong>Tune (mini-cycle or quarterly)&lt;/strong>: here there is a cost. Fine-tuning a LoRA adapter over Llama 3 70B with a dataset of a few thousand examples costs on the order of 2-8 hours on a single H100. On 4 H100s with tensor parallel: 30-90 min. It fits comfortably into any low-demand overnight window.&lt;/li>
&lt;li>&lt;strong>Full eval suite&lt;/strong>: minutes on an engine with prefix caching active (covered in &lt;a href="https://blog.lo0.es/en/posts/pagedattention-vllm-block-manager/">pagedattention deep-dive&lt;/a>).&lt;/li>
&lt;li>&lt;strong>Canary deployment&lt;/strong>: zero additional cost, since the new adapter coexists in the same engine via multi-LoRA hot-swap.&lt;/li>
&lt;/ul>
&lt;p>For an &lt;strong>RTX 4090&lt;/strong> serving Llama 3 8B with a small team: a monthly or quarterly scheduled retrain overnight, an enriched dataset of 50-100 cases per cycle, annotation with self-hosted Argilla on the same node. Plenty.&lt;/p>
&lt;p>For a &lt;strong>4×H100 SXM cluster&lt;/strong> serving several tenants: an enriched dataset segregated by tenant (each with its own holdout and eval suite), a retrain pipeline orchestrated with Argo Workflows or equivalent, a centralised MLflow registry, multi-LoRA hot-swap per tenant.&lt;/p>
&lt;h2 id="common-operational-traps">Common operational traps&lt;/h2>
&lt;p>&lt;strong>The open loop.&lt;/strong> The system captures feedback, saves it to a table, and there it dies. Nobody triages, nobody enriches, nobody retrains. The deployed model ages silently. Fix: an explicit internal SLO (for example, &amp;ldquo;any feedback more than a week old without triage gets reported in standup&amp;rdquo;), with an assigned owner.&lt;/p>
&lt;p>&lt;strong>Human feedback that gets lost.&lt;/strong> Thumbs-downs with no structured capture (the event is logged but the reason is not), or the reason is logged but nobody indexes it for queries. Fix: an explicit schema like the one above, a weekly dashboard of &amp;ldquo;top thumbs-down reasons&amp;rdquo;.&lt;/p>
&lt;p>&lt;strong>Undefined cadence.&lt;/strong> &amp;ldquo;We will retrain when we need to&amp;rdquo; — never. Fix: a date in the calendar + 3-5 written thresholds.&lt;/p>
&lt;p>&lt;strong>No holdout test set.&lt;/strong> The enriched dataset is mixed with the golden dataset both to train AND to evaluate. The adapter looks like it improved because it &amp;ldquo;memorised&amp;rdquo; the enriched cases, but it generalises badly to new similar cases. Fix: a holdout reserved before training; the eval against the holdout is the metric that decides promotion.&lt;/p>
&lt;p>&lt;strong>Ad-hoc triage by one person.&lt;/strong> The senior data scientist triages when they can; while they are on holiday it piles up; they come back and give up because there are 400 incidents waiting. Fix: automate 70-80 % with an LLM-as-classifier, leave only the hard part to a human; rotate the &amp;ldquo;triage oncall&amp;rdquo; so one person does not get saturated.&lt;/p>
&lt;p>&lt;strong>Promotion without canary.&lt;/strong> The adapter passes the offline eval and is deployed to 100 % directly. A regression in production takes a while to be detected, until the online metrics show it, and by then the damage is done. Fix: a mandatory 5-10 % canary for 24-72 h.&lt;/p>
&lt;p>&lt;strong>No reproducibility of the original incident.&lt;/strong> The team goes to investigate why the model failed in the 22 May incident and discovers the prompt was different (it was changed two weeks ago), the model was too, and the logs did not keep the RAG context. Fix: strong traceability (covered in &lt;a href="https://blog.lo0.es/en/posts/prompt-versioning-contract-five-word-change/">prompt versioning&lt;/a> and &lt;a href="https://blog.lo0.es/en/posts/mcp-deep-observability-opentelemetry/">MCP observability&lt;/a>). Without reproducibility, retrain is guesswork.&lt;/p>
&lt;p>&lt;strong>The enriched dataset contaminates the Eval data.&lt;/strong> The team confuses &amp;ldquo;cases where it failed&amp;rdquo; (which go into the enriched training set) with &amp;ldquo;the golden regression dataset&amp;rdquo; (which has to stay stable to detect drift). Mixing them invalidates the eval. Fix: two different datasets, two different routes.&lt;/p>
&lt;h2 id="recommended-operational-pattern-the-quarterly-cycle-on-one-screen">Recommended operational pattern: the quarterly cycle on one screen&lt;/h2>
&lt;p>A serious team with Retrain properly implemented has this flow every 3 months:&lt;/p>
&lt;p>&lt;strong>Week 1 (cycle close)&lt;/strong>: new capture is frozen for the cycle, a snapshot is taken of the accumulated feedback. Automated report: how many thumbs-downs, how many incidents triaged, the distribution by category, top patterns.&lt;/p>
&lt;p>&lt;strong>Week 2 (triage and annotation)&lt;/strong>: the MLE team plus annotators process the untriaged &lt;code>model issue&lt;/code> cases. Human annotation in Argilla. Cross-validation on samples.&lt;/p>
&lt;p>&lt;strong>Week 3 (training and eval)&lt;/strong>: the pipeline is launched with dataset = golden + this_quarter&amp;rsquo;s_enriched - holdout. Fine-tuning the adapter overnight. Eval against the full suite + holdout. If it passes the gates, candidate &lt;code>v_new&lt;/code>.&lt;/p>
&lt;p>&lt;strong>Week 4 (canary and promotion)&lt;/strong>: deploy the candidate as an alternative adapter in vLLM, route 5-10 % of traffic to the candidate for 48-72 h. Online metrics: latency, complaint rate, implicit eval in production. If everything is OK, full promotion; if not, rollback and analysis.&lt;/p>
&lt;p>&lt;strong>Week 5+ (next cycle)&lt;/strong>: the &lt;code>v_new&lt;/code> adapter is now &lt;code>production&lt;/code>. Feedback capture for the next quarter begins. The previous &lt;code>v_old&lt;/code> goes to &lt;code>Archived&lt;/code> but stays accessible for historical reproducibility.&lt;/p>
&lt;p>Quarterly, that cycle plus whatever incident-driven mini-cycles show up in between. Operational, predictable, auditable.&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>Online DPO and continuous on-policy learning&lt;/strong>: how the cycle shortens to hours or days (Fast-Slow Chasing, iterative RLOO). 2026 state of the art, still emerging in production.&lt;/li>
&lt;li>&lt;strong>Machine unlearning for GDPR&lt;/strong>: when a user exercises the right to be forgotten and their interactions formed part of the enriched dataset of an adapter in production. Negative LoRA, selective retraining.&lt;/li>
&lt;li>&lt;strong>Constitutional AI runtime&lt;/strong>: continuous alignment that replaces or complements periodic retraining.&lt;/li>
&lt;li>&lt;strong>Eval gates with metamorphic testing&lt;/strong>: evaluating robustness against input perturbations (typos, paraphrasing, language) as part of the promotion gate.&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/six-stage-llmops-pipeline-architecture-and-deep-dive/">The six-stage LLMOps pipeline&lt;/a> — the master map where Retrain is stage 6. This post goes into the detail of that box.&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> — the Tune mechanics that produce the new adapter of the cycle 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 eval suites that act as the gate in promotion sub-process 5.&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 Prompts&lt;/a> — the cross-cutting component that guarantees reproducibility of the original incident when it comes to be triaged.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/data-versioning-llmops-dvc-lakefs-reproducible-golden-dataset/">Data versioning for LLMOps: DVC, lakeFS and a reproducible golden dataset&lt;/a> — Retrain sub-process 3 enriches a dataset; this post goes into the detail of how to version it, its schema and its lineage.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/ebpf-local-inference-statistical-drift-detection/">eBPF in local inference and statistical drift detection&lt;/a> — the drift signals that fire the incident-driven retrain.&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 safety / jailbreak incidents that also fire an incident-driven retrain.&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 OTel &lt;code>gen_ai.*&lt;/code> tracing that ties each piece of feedback to its complete trace, a necessary condition for triaging well.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/modern-alignment-dpo-kto-orpo-simpo/">Modern alignment: DPO, KTO, ORPO and SimPO&lt;/a> — the methods that consume the enriched dataset of sub-process 3 to produce the new adapter. KTO fits directly with binary 👍/👎 feedback; DPO with regenerate-as-rejected.&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 mechanism that automates the triaging and prioritisation of sub-process 1 (incident classification) without depending on complete human labelling.&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: the single translator with a thousand glossaries&lt;/a> — the last mile of the cycle: the adapter produced by retraining is served with SGMV/S-LoRA alongside the other N-1 concurrent adapters without touching the base or restarting the server.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/iso-42001-aims-on-premise-llm-platform/">ISO/IEC 42001: the AI system&amp;rsquo;s operations manual&lt;/a> — the incident-driven retrain loop described here materialises clause 10 (improvement) of the AIMS: severity HIGH incidents → non-conformity → root cause → correction → documented verification of effectiveness.&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 incident-driven loop is the operational piece that closes Arts. 72 (post-market monitoring) and 73 (reporting of serious incidents within legal deadlines of 2-15 days); without it, the Regulation&amp;rsquo;s deadlines are unreachable.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/canary-blue-green-shadow-llm-models/">Canary, blue-green and shadow for LLM models&lt;/a> — the adapter produced by Retrain sub-process 4 enters the cluster through the progressive rollout; without a canary, an incident-driven correction can introduce a regression that passes the offline gates but not the real-traffic ones.&lt;/li>
&lt;/ul>
&lt;h2 id="references">References&lt;/h2>
&lt;ul>
&lt;li>Argilla documentation, &lt;em>Building Datasets for LLM Fine-Tuning&lt;/em>: &lt;a href="https://argilla.io/docs">https://argilla.io/docs&lt;/a>.&lt;/li>
&lt;li>Label Studio documentation, &lt;em>LLM Annotation&lt;/em>: &lt;a href="https://labelstud.io/templates/llm">https://labelstud.io/templates/llm&lt;/a>.&lt;/li>
&lt;li>Langfuse documentation, &lt;em>User Feedback and Dataset Management&lt;/em>: &lt;a href="https://langfuse.com/docs/scores/user-feedback">https://langfuse.com/docs/scores/user-feedback&lt;/a>.&lt;/li>
&lt;li>MLflow Model Registry stages: &lt;a href="https://mlflow.org/docs/latest/model-registry.html">https://mlflow.org/docs/latest/model-registry.html&lt;/a>.&lt;/li>
&lt;li>Ethayarajh et al., &lt;em>KTO: Model Alignment as Prospect Theoretic Optimization&lt;/em> (2024) — reference for the feedback cycle as an alignment signal.&lt;/li>
&lt;li>Google Cloud, &lt;em>Continuous Training and MLOps for GenAI&lt;/em> (2025).&lt;/li>
&lt;li>DataRobot, &lt;em>MLOps Best Practices: Closing the Loop&lt;/em> (2025).&lt;/li>
&lt;li>Eugene Yan, &lt;em>Feedback Loops in LLM Systems&lt;/em> (blog, 2025).&lt;/li>
&lt;/ul></description></item><item><title>Prompt versioning: the contract that stops a five-word change sinking your system</title><link>https://blog.lo0.es/en/posts/prompt-versioning-contract-five-word-change/</link><pubDate>Fri, 22 May 2026 07:30:00 +0200</pubDate><guid>https://blog.lo0.es/en/posts/prompt-versioning-contract-five-word-change/</guid><description>&lt;h2 id="tldr">TL;DR&lt;/h2>
&lt;p>In a classic software system, the most dangerous line a team can change is a SQL migration. In an LLM system, it is a line of prompt. The prompt determines the output as much as the model or more, it does not show up in unit tests, it does not appear in the logs by default, and if it is changed without leaving a trail there is no way to know which version generated which response. &lt;strong>Prompt versioning is the discipline that turns the prompt into a first-class artefact&lt;/strong>: with a unique identifier, history, deployment labels, an associated eval suite, and per-request traceability. The field has settled on three primitives (immutable version, mutable label, read cache) and two dominant tools (Langfuse OSS with a built-in UI, MLflow Prompts integrated into the registry since MLflow 3.10). This article covers the pattern at first level: why it matters, how it is implemented, which tool to choose, and how it fits with Eval, Deploy and Observe.&lt;/p>
&lt;h2 id="you-are-here-cross-cutting-touches-data-tune-eval-deploy-and-observe">You are here: cross-cutting (touches Data, Tune, Eval, Deploy and Observe)&lt;/h2>
&lt;p>Prompt versioning does not live in one stage, it &lt;strong>cuts across five&lt;/strong>. It appears as a cross-cutting component in the master map of the &lt;a href="https://blog.lo0.es/en/posts/six-stage-llmops-pipeline-architecture-and-deep-dive/">six-stage LLMOps pipeline&lt;/a> precisely for that reason: the prompt version is necessary metadata at every stage, not the responsibility of a single one.&lt;/p>
&lt;div class="diagram" style="max-width:780px;margin:1rem auto;">
&lt;svg viewBox="0 0 780 130" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="you are here: cross-cutting prompt versioning">
&lt;style>.box{stroke:#444;stroke-width:1.4;rx:6}.cross{fill:#ffe9d6;stroke-width:3;stroke:#c66;rx:6}.idle{fill:#f4f4f4}.lbl{font:600 12px sans-serif;fill:#222}.sm{font:11px sans-serif;fill:#444}.arr{stroke:#666;stroke-width:1.4;fill:none;marker-end:url(#pvm)}.cyc{stroke:#888;stroke-width:1.2;fill:none;stroke-dasharray:4 2;marker-end:url(#pvm)}&lt;/style>
&lt;defs>&lt;marker id="pvm" 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: CROSS-CUTTING · prompt versioning cuts across every active stage&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 idle"/>&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;rect x="30" y="95" width="735" height="25" class="cross"/>
&lt;text x="397" y="112" text-anchor="middle" class="sm">Prompt registry (Langfuse / MLflow Prompts) · versioning · labels · cache · trace per request&lt;/text>
&lt;/svg>
&lt;/div>
&lt;h2 id="the-master-analogy-the-prompt-is-an-invisible-sql-migration">The master analogy: the prompt is an invisible SQL migration&lt;/h2>
&lt;p>A serious backend team would never accept someone modifying a column directly in production without going through a versioned migration. Even if the change &amp;ldquo;works&amp;rdquo; at the time, without a migration there is no way to:&lt;/p>
&lt;ul>
&lt;li>Reproduce the previous state if something fails.&lt;/li>
&lt;li>Know who applied the change and when.&lt;/li>
&lt;li>Apply the same change in staging before prod.&lt;/li>
&lt;li>Test the new version against an automated suite before promoting.&lt;/li>
&lt;li>Know, two months later, why the table has the shape it has.&lt;/li>
&lt;/ul>
&lt;p>The LLM prompt occupies exactly that position in an inference system. Changing &lt;code>&amp;quot;You are a helpful assistant.&amp;quot;&lt;/code> to &lt;code>&amp;quot;You are a helpful, concise assistant. Answer in fewer than 3 sentences.&amp;quot;&lt;/code> can:&lt;/p>
&lt;ul>
&lt;li>Cut the average cost per response by 30 % (the responses are shorter).&lt;/li>
&lt;li>Or degrade quality in a segment where concision breaks necessary nuance.&lt;/li>
&lt;li>Or change the distribution of tools the agent decides to invoke.&lt;/li>
&lt;li>Or alter the behaviour of the downstream LLM judge that assumes a certain length.&lt;/li>
&lt;/ul>
&lt;p>And most importantly: if the change is made by &lt;strong>editing a constant in the app code and deploying&lt;/strong>, when two weeks later someone asks &lt;em>&amp;ldquo;why did the complaint rate go up in the finance segment?&amp;rdquo;&lt;/em>, &lt;strong>there is no way to know which prompt was being served at each moment&lt;/strong>. The logs store the response and, with luck, the model invoked; the prompt is rarely stored explicitly.&lt;/p>
&lt;p>Prompt versioning solves the same problem Flyway/Liquibase/Alembic solved for SQL: turning an invisible change into an auditable artefact.&lt;/p>
&lt;h2 id="the-three-primitives-of-the-pattern">The three primitives of the pattern&lt;/h2>
&lt;p>Whatever the tool, the systems that work in 2026 share three operational primitives worth fixing before looking at products.&lt;/p>
&lt;h3 id="1-immutable-version">1. Immutable version&lt;/h3>
&lt;p>Every time the prompt content changes (template, system message, available variables, recommended model parameters such as temperature), &lt;strong>a new version&lt;/strong> is generated with a unique identifier. The version is &lt;strong>immutable&lt;/strong>: once created, it is not overwritten; if something needs changing, v+1 is created.&lt;/p>
&lt;pre tabindex="0">&lt;code>prompt_id: customer_support_v3
versions:
v1 (2026-03-12): &amp;#34;Eres un asistente de soporte...&amp;#34;
v2 (2026-04-08): &amp;#34;Eres un asistente de soporte... formato JSON...&amp;#34;
v3 (2026-05-21): &amp;#34;Eres un asistente de soporte... formato JSON... 3 frases máx...&amp;#34;
&lt;/code>&lt;/pre>&lt;p>Immutability is what allows a trace from two months ago to be reproduced: if the trace says &amp;ldquo;&lt;code>customer_support_v3@v2&lt;/code> was served&amp;rdquo;, version v2 exists &lt;strong>literally&lt;/strong> and can be reloaded.&lt;/p>
&lt;h3 id="2-mutable-label-deployment-alias">2. Mutable label (deployment alias)&lt;/h3>
&lt;p>Versions are immutable, but &lt;strong>which version is in production changes&lt;/strong>. That decision is materialised in &lt;strong>labels&lt;/strong>: pointers with a semantic name (&lt;code>production&lt;/code>, &lt;code>staging&lt;/code>, &lt;code>canary&lt;/code>) that point at a specific version and can be re-pointed.&lt;/p>
&lt;pre tabindex="0">&lt;code>prompt_id: customer_support_v3
labels:
production → v2 (servida al 100% del tráfico)
canary → v3 (servida al 5% del tráfico via gateway)
staging → v3
&lt;/code>&lt;/pre>&lt;p>Promoting a version is &lt;strong>moving a label&lt;/strong>, not editing the prompt. Rollback is &lt;strong>moving the label back&lt;/strong>, not copying text. The operation reduces to an atomic mutation of a &lt;code>(label, version)&lt;/code> tuple.&lt;/p>
&lt;h3 id="3-read-cache">3. Read cache&lt;/h3>
&lt;p>The prompt is read on &lt;strong>every request to the model&lt;/strong>. If every read calls the prompt registry service, you add latency and a dependency. The standard solution is a &lt;strong>local cache&lt;/strong> in the client (TTL on the order of minutes) that invalidates when the label changes or when the TTL expires.&lt;/p>
&lt;p>Langfuse implements a native client cache with configurable TTL and lazy invalidation; MLflow Prompts leaves the responsibility to the client or to a gateway layer. In both cases, in production the client serves the prompt from memory with negligible overhead (&amp;lt;1 ms), and only goes to the registry when it refreshes.&lt;/p>
&lt;pre tabindex="0">&lt;code>┌──────────────────┐
│ Client (app) │
│ - local cache TTL=60s
│ - lookup label &amp;#34;production&amp;#34;
│ - gets template
│ - renders variables
│ - sends to LLM
└─────────┬────────┘
│ (when the TTL expires or a change event arrives)
▼
┌──────────────────┐
│ Prompt registry │
│ - Langfuse / MLflow
│ - GET label=&amp;#34;production&amp;#34;
│ - response: version_id + template
└──────────────────┘
&lt;/code>&lt;/pre>&lt;p>With these three primitives, &lt;strong>any reasonable tool is equivalent&lt;/strong> in the essentials. What distinguishes one from another is UI, integrations, RBAC, eval integration, and so on.&lt;/p>
&lt;h2 id="the-two-dominant-tools-in-2026">The two dominant tools in 2026&lt;/h2>
&lt;p>The field has converged on two main options. Any serious production deployment uses one of the two (sometimes both, for different teams).&lt;/p>
&lt;h3 id="langfuse-oss-prompt-management-ui-built-in">Langfuse (OSS, prompt-management UI built in)&lt;/h3>
&lt;p>Langfuse is the &lt;strong>prompt-first&lt;/strong> system: it was born for tracing and observability, and prompt management is one of its core layers. Key features for versioning:&lt;/p>
&lt;ul>
&lt;li>Built-in UI to create, edit and version prompts. Versions are generated automatically on save; the history is visible and diffable.&lt;/li>
&lt;li>Arbitrary labels beyond the usual ones (&lt;code>production&lt;/code>, &lt;code>latest&lt;/code>). You can define &lt;code>eu-prod&lt;/code>, &lt;code>internal-only&lt;/code>, &lt;code>customer-a&lt;/code> for fine-grained routing.&lt;/li>
&lt;li>Native client cache in the official SDKs (Python, JS), with configurable TTL, event-driven invalidation and fallback to the last-known-good if the registry is down.&lt;/li>
&lt;li>Native tracing integration: when you record a call to the LLM, Langfuse automatically associates the &lt;code>prompt_id@version&lt;/code> that served it. In the UI you see: this trace, this span, this prompt version X.&lt;/li>
&lt;li>Eval integration: Langfuse lets you register eval suites that fire when a new prompt version is created. The results stay linked to the &lt;code>prompt_id@version&lt;/code> and are the natural gating for promoting &lt;code>staging → production&lt;/code>.&lt;/li>
&lt;li>Self-hosted or cloud: the core is OSS (MIT), runs on Docker compose or Helm; the cloud version adds SLA, SSO and support.&lt;/li>
&lt;/ul>
&lt;p>When Langfuse suits:&lt;/p>
&lt;ul>
&lt;li>Teams that want a rich UI so product/PM/analyst can manage prompts without touching code.&lt;/li>
&lt;li>OSS-first deployments where control of the runtime and of persistence is a requirement (on-premise, ENS).&lt;/li>
&lt;li>When LLM observability is already in Langfuse: prompt management is marginal in setup.&lt;/li>
&lt;/ul>
&lt;h3 id="mlflow-prompts-included-in-mlflow-310-march-2026">MLflow Prompts (included in MLflow 3.10, March 2026)&lt;/h3>
&lt;p>MLflow Prompts is the classic MLOps ecosystem&amp;rsquo;s answer for LLMs. Features:&lt;/p>
&lt;ul>
&lt;li>Integrated into the MLflow Model Registry: prompts are first-class registry artefacts, with the same stage semantics (&lt;code>Staging&lt;/code>, &lt;code>Production&lt;/code>, &lt;code>Archived&lt;/code>) MLOps teams already know.&lt;/li>
&lt;li>API consistent with the rest of MLflow: &lt;code>mlflow.register_prompt()&lt;/code>, &lt;code>mlflow.load_prompt(name, stage=&amp;quot;Production&amp;quot;)&lt;/code>. The learning curve for teams already using MLflow for models is nil.&lt;/li>
&lt;li>Automatic versioning with a numeric &lt;code>version_id&lt;/code> (1, 2, 3, &amp;hellip;) and optional comments on promotion.&lt;/li>
&lt;li>No built-in UI dedicated to prompts (the MLflow UI does the job, but it is designed for models; the flow is less polished than in Langfuse).&lt;/li>
&lt;li>No native GenAI-aware tracing (MLflow Tracing in the 3.10 GenAI dashboard provides it, but the trace↔prompt integration is more manual than in Langfuse).&lt;/li>
&lt;li>Compatible with any model registry backend MLflow supports (filesystem, Postgres, MySQL, S3, GCS, Azure Blob).&lt;/li>
&lt;/ul>
&lt;p>When MLflow Prompts suits:&lt;/p>
&lt;ul>
&lt;li>Teams already operating MLflow for classic ML who want to extend the same discipline to LLMs without adding vendors.&lt;/li>
&lt;li>Deployments where the centre of gravity is the model registry and the prompt is one more artefact.&lt;/li>
&lt;li>CI/CD pipelines that already speak MLflow (CLI, REST API).&lt;/li>
&lt;/ul>
&lt;h3 id="comparison">Comparison&lt;/h3>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Feature&lt;/th>
&lt;th>Langfuse&lt;/th>
&lt;th>MLflow Prompts&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Core licence&lt;/td>
&lt;td>MIT (OSS)&lt;/td>
&lt;td>Apache 2.0 (OSS)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Prompt-first UI&lt;/td>
&lt;td>✅&lt;/td>
&lt;td>⚠️ via Model Registry&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Immutable versioning&lt;/td>
&lt;td>✅&lt;/td>
&lt;td>✅&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Mutable labels&lt;/td>
&lt;td>✅ (arbitrary)&lt;/td>
&lt;td>✅ (Staging/Production/Archived)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Native client cache&lt;/td>
&lt;td>✅&lt;/td>
&lt;td>❌ (DIY)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Integrated tracing&lt;/td>
&lt;td>✅ native&lt;/td>
&lt;td>⚠️ via MLflow Tracing&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Eval gating on promotion&lt;/td>
&lt;td>✅&lt;/td>
&lt;td>⚠️ DIY with MLflow Recipes&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Easy self-hosting&lt;/td>
&lt;td>✅ Docker/Helm&lt;/td>
&lt;td>✅ standard MLflow&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Curve if you come from MLOps&lt;/td>
&lt;td>medium&lt;/td>
&lt;td>nil&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Curve if you come from DevOps&lt;/td>
&lt;td>nil&lt;/td>
&lt;td>medium&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>In May 2026, the most widespread hybrid pattern is to use MLflow for the models+adapters registry and Langfuse for prompts+tracing, connected by &lt;code>trace_id&lt;/code> and &lt;code>prompt_id&lt;/code> travelling in the OpenTelemetry span attributes. Covered in &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/mcp-deep-observability-opentelemetry/">MCP observability&lt;/a>.&lt;/p>
&lt;h2 id="minimum-schema-of-a-versioned-prompt">Minimum schema of a versioned prompt&lt;/h2>
&lt;p>Whatever the tool, what the registry stores in each version has a reasonable minimum schema:&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"># prompt_id: customer_support_v3, version: 3&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">template&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">system&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">|&lt;/span>&lt;span class="sd">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="sd"> Eres un asistente de soporte de {{company_name}}.
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="sd"> Responde en español neutral, máximo 3 frases.
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="sd"> Formato de respuesta: JSON {&amp;#34;answer&amp;#34;: &amp;#34;...&amp;#34;, &amp;#34;needs_human&amp;#34;: bool}&lt;/span>&lt;span class="w">
&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&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">|&lt;/span>&lt;span class="sd">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="sd"> Pregunta del cliente: {{user_message}}
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="sd"> Contexto del ticket: {{ticket_context}}&lt;/span>&lt;span 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">variables&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">required&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="l">company_name, user_message, ticket_context]&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">defaults&lt;/span>&lt;span class="p">:&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">recommended_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="s2">&amp;#34;llama-3-70b-instruct&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">temperature&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">0.3&lt;/span>&lt;span class="w">
&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_tokens&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">300&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">response_format&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;json_object&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">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">author&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;mlops@empresa.com&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">created_at&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;2026-05-21T14:23:00Z&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">commit_message&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;Añade límite de 3 frases tras feedback ticket #1842&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">eval_suite&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;customer_support_v3_evals&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">related_traces&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;trace_id_x&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;trace_id_y&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>This is &lt;strong>the minimum contract&lt;/strong>. What sets a serious deployment apart:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>&lt;code>variables.required&lt;/code>&lt;/strong> is validated in the client before sending to the model. A missing variable blows up at client time, not in a confusing model response.&lt;/li>
&lt;li>&lt;strong>&lt;code>recommended_params.model&lt;/code>&lt;/strong> ties the prompt version to a model. Changing model opens a debate (does the new version work with Llama 3 70B and with GPT-4o?). If it is not tied, the model is one more variable that wrecks reproducibility.&lt;/li>
&lt;li>&lt;strong>&lt;code>metadata.eval_suite&lt;/code>&lt;/strong> is what the eval suites hook into: on creating v3, MLflow/Langfuse fires &lt;code>customer_support_v3_evals&lt;/code> automatically.&lt;/li>
&lt;/ul>
&lt;h2 id="integration-with-eval-gates-governed-promotion">Integration with eval gates: governed promotion&lt;/h2>
&lt;p>The real value of prompt versioning appears when it is integrated with eval. The canonical pattern:&lt;/p>
&lt;ol>
&lt;li>&lt;strong>Developer edits the prompt in the UI&lt;/strong> (Langfuse) or via the &lt;strong>API&lt;/strong> (MLflow). &lt;code>v4&lt;/code> is created.&lt;/li>
&lt;li>&lt;strong>Automatic trigger&lt;/strong>: the &lt;code>prompt_created&lt;/code> event fires the associated eval suite (the &lt;code>eval_suite&lt;/code> in the metadata).&lt;/li>
&lt;li>&lt;strong>The suite runs&lt;/strong> against the golden dataset (questions+answers labelled by a human). Covered at first level in &lt;a href="https://blog.lo0.es/en/posts/evals-llm-layer-after-tracing/">the evals post&lt;/a>.&lt;/li>
&lt;li>&lt;strong>Results are attached to the version&lt;/strong>: &lt;code>v4&lt;/code> now has &lt;code>eval_score: 0.84, regression_vs_v3: -0.03&lt;/code>.&lt;/li>
&lt;li>&lt;strong>Promotion gate&lt;/strong>: if &lt;code>eval_score &amp;gt;= threshold&lt;/code> and &lt;code>regression &amp;lt; tolerance&lt;/code>, the &lt;code>staging&lt;/code> label moves to &lt;code>v4&lt;/code> automatically. If not, it alerts the developer.&lt;/li>
&lt;li>&lt;strong>Manual promotion to &lt;code>production&lt;/code>&lt;/strong>: with the eval passed, someone with permission moves &lt;code>production&lt;/code> from &lt;code>v3&lt;/code> to &lt;code>v4&lt;/code>. Atomic, auditable, reversible.&lt;/li>
&lt;/ol>
&lt;pre tabindex="0">&lt;code>Developer edits prompt → v4 created
│
▼
[eval suite trigger]
│
▼
Golden dataset 200 examples
│
▼
score = 0.84 (vs 0.87 for v3)
│
├── If it passes the threshold → label staging → v4
│ └── Manual promotion to production after review
└── If it does not pass → block + alert the developer
&lt;/code>&lt;/pre>&lt;p>This flow turns the prompt change from &amp;ldquo;someone touched the code and we prayed&amp;rdquo; into &amp;ldquo;a prompt change is a PR that passes CI&amp;rdquo;. It is the same discipline classic MLOps applied to models.&lt;/p>
&lt;h2 id="per-request-traceability-which-version-served-each-response">Per-request traceability: which version served each response&lt;/h2>
&lt;p>The last piece is &lt;strong>operational traceability&lt;/strong>: given a model response in production, which prompt version generated it?&lt;/p>
&lt;p>The pattern is to propagate the version as a &lt;strong>span attribute&lt;/strong> in OpenTelemetry, following the &lt;code>gen_ai.*&lt;/code> semantic conventions we covered in &lt;a href="https://blog.lo0.es/en/posts/mcp-deep-observability-opentelemetry/">MCP observability&lt;/a>:&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"># In the client (common pseudo-code)&lt;/span>
&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">registry&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">load&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;customer_support_v3&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">label&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;production&amp;#34;&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="c1"># v3 → v_id=14&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">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;llm_call&amp;#34;&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;gen_ai.prompt.id&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s2">&amp;#34;customer_support_v3&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;gen_ai.prompt.version&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s2">&amp;#34;14&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;gen_ai.prompt.label&amp;#34;&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>&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;gen_ai.request.model&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">prompt&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">model&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">response&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">llm&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">complete&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">prompt&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">render&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">user_message&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">msg&lt;/span>&lt;span class="p">),&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">params&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">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;gen_ai.usage.input_tokens&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">response&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">usage&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">input&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;gen_ai.usage.output_tokens&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">response&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">usage&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">output&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>In any trace (Langfuse, Phoenix, Jaeger, Honeycomb) you can see which exact version served that response. In an incident (&amp;ldquo;customer X received this on 22 May&amp;rdquo;) you reproduce &lt;strong>literally&lt;/strong> the version and the model that generated the output.&lt;/p>
&lt;p>Without that traceability, the incident stays an anecdote; with it, it is debuggable.&lt;/p>
&lt;h2 id="applied-to-typical-on-premise-hardware">Applied to typical on-premise hardware&lt;/h2>
&lt;p>Prompt versioning is a &lt;strong>computationally light&lt;/strong> layer compared with the inference engine or the fine-tuning pipeline. Its requirements:&lt;/p>
&lt;ul>
&lt;li>Storage: the prompt registry typically weighs megabytes (hundreds to thousands of prompts with their versions). Postgres with a &lt;code>prompts(id, version, template, params jsonb, metadata jsonb, created_at)&lt;/code> schema is more than enough. Langfuse uses Postgres by default; MLflow uses it for metadata (the blobs go to object storage or the filesystem).&lt;/li>
&lt;li>Registry compute: a small instance (1-2 vCPU, 2 GB RAM) serves tens of thousands of reads per minute if the client cache is enabled. Without a cache, it scales linearly with QPS but is still trivial.&lt;/li>
&lt;li>Triggered eval compute: here there is a real cost. Every time a new version is created, the eval suite runs. If the suite does LLM-as-judge over 200 examples and each eval costs 4 K tokens, one promotion costs on the order of 1 M tokens, minutes on a decent cluster, seconds if the suite already has its hot prefix cache.&lt;/li>
&lt;/ul>
&lt;p>For an &lt;strong>RTX 4090&lt;/strong> serving Llama 3 8B with a self-hosted prompt registry (Langfuse or MLflow): the registry runs on the same node in a sidecar container, the local app caches in RAM, the eval triggers run against the same inference engine at low priority. Full setup in a morning.&lt;/p>
&lt;p>For a &lt;strong>4×H100 SXM cluster&lt;/strong> serving a large model to several tenants: registry in a dedicated K8s pod with replicated Postgres, eval suites running in pods with the &lt;code>spot&lt;/code> priority class (covered in &lt;a href="https://blog.lo0.es/en/posts/gpu-cluster-as-multi-tenant-platform/">the cluster as a platform&lt;/a>), OTel tracing propagating &lt;code>prompt_id+version&lt;/code> to a central Langfuse.&lt;/p>
&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>Prompts hardcoded in the app code.&lt;/strong> The most common antipattern. The prompt lives in a &lt;code>prompts.py&lt;/code> file or &lt;code>templates/customer.txt&lt;/code> that gets deployed with the app. There is no real versioning (git history is not a substitute: it does not tie commit ↔ production trace operationally). Migrating to a registry is 1-2 sprints of work; it is worth every hour.&lt;/p>
&lt;p>&lt;strong>Badly calibrated cache.&lt;/strong> A TTL of hours with a mutable label means a rollback takes time to propagate. A TTL of seconds overloads the registry. The reasonable default is &lt;strong>60-300 seconds&lt;/strong> with event-driven invalidation (the registry emits a message to Kafka/Redis when a label changes, and clients invalidate immediately).&lt;/p>
&lt;p>&lt;strong>Unvalidated variables.&lt;/strong> The template uses &lt;code>{{user_name}}&lt;/code> but the app passes &lt;code>{{username}}&lt;/code>. The render produces a prompt with a literal &lt;code>{{user_name}}&lt;/code>. The model answers something bizarre and nobody knows why. Validating &lt;strong>required variables in the client&lt;/strong> before sending to the model is the minimum discipline.&lt;/p>
&lt;p>&lt;strong>Prompts inside chains evaluated at runtime.&lt;/strong> If your stack uses LangChain, LlamaIndex or similar with chains that compose prompts at runtime, the &lt;strong>final&lt;/strong> prompt the model sees may not be in the registry because it was composed from several fragments. Solutions: either register the chains as artefacts, or log the effective composed prompt in every trace.&lt;/p>
&lt;p>&lt;strong>Eval suite not hooked to the &lt;code>prompt_id&lt;/code>.&lt;/strong> Without that link, a prompt change gets promoted without passing evals. The integration has to be &lt;strong>a field in the prompt metadata&lt;/strong> (&lt;code>eval_suite: ...&lt;/code>) that the system reads and fires automatically. If it depends on the developer &amp;ldquo;remembering&amp;rdquo;, the pattern will fail.&lt;/p>
&lt;p>&lt;strong>Non-existent RBAC roles.&lt;/strong> Anyone with access to the UI can move &lt;code>production&lt;/code> to any version. Without separating &lt;code>editor&lt;/code> (creates versions) from &lt;code>releaser&lt;/code> (moves production labels), a junior developer can break production with an accidental promotion. Langfuse Enterprise has granular RBAC; MLflow has it via the server backend with per-experiment/registry permissions.&lt;/p>
&lt;p>&lt;strong>Prompts with sensitive data inline.&lt;/strong> The prompt template includes few-shot examples with real names, addresses, customer IDs. The registry stores that indefinitely. Under GDPR, the right to erasure applies to the registry too. Good practice: &lt;strong>variables for sensitive data&lt;/strong>, not inline; periodic audit of the registry content.&lt;/p>
&lt;h2 id="recommended-operational-pattern-the-cycle-on-one-screen">Recommended operational pattern: the cycle on one screen&lt;/h2>
&lt;p>A serious team with prompt versioning properly set up has the following cycle, repeatable and cheap:&lt;/p>
&lt;ol>
&lt;li>&lt;strong>Developer opens a PR in the repo&lt;/strong>: changes the app code if necessary, but &lt;strong>does not touch the prompt&lt;/strong> there.&lt;/li>
&lt;li>&lt;strong>Edits the prompt in the Langfuse/MLflow UI&lt;/strong>: creates &lt;code>v_new&lt;/code>. Adds a commit message (&amp;ldquo;adds a 3-sentence limit after feedback on ticket #1842&amp;rdquo;).&lt;/li>
&lt;li>&lt;strong>The eval suite fires automatically&lt;/strong>: it runs against the golden dataset, results appear in the UI within minutes.&lt;/li>
&lt;li>&lt;strong>If it passes eval&lt;/strong>: the &lt;code>staging&lt;/code> label moves to &lt;code>v_new&lt;/code> automatically. The developer can test staging with controlled traffic.&lt;/li>
&lt;li>&lt;strong>Human review&lt;/strong> (1-2 people, optional depending on severity): approval.&lt;/li>
&lt;li>&lt;strong>Promotion to &lt;code>production&lt;/code>&lt;/strong>: move the label, atomic. The client caches for 60-300 s, then serves the new version.&lt;/li>
&lt;li>&lt;strong>Observe&lt;/strong>: in Langfuse/Phoenix, production metrics and eval scores are segmented by prompt version. If the score drops with &lt;code>v_new&lt;/code>, alert.&lt;/li>
&lt;li>&lt;strong>If there is a serious regression&lt;/strong>: rollback is &lt;strong>moving the label back&lt;/strong>. A 5-second operation.&lt;/li>
&lt;/ol>
&lt;p>Every step is audited, every decision leaves a trail, every rollback is an atomic operation. This is what separates a GenAI system from &amp;ldquo;demos that worked once&amp;rdquo; and makes it operable for years.&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>Automatic prompt optimisation&lt;/strong>: techniques such as DSPy, TextGrad, PromptBreeder that generate prompt candidates and optimise them against a measurable objective. The extension of versioning where the &amp;ldquo;developer&amp;rdquo; can be an optimiser.&lt;/li>
&lt;li>&lt;strong>Prompt injection and red teaming&lt;/strong>: integrating versioning with the adversarial evaluation flow. Partially covered in &lt;a href="https://blog.lo0.es/en/posts/llm-guardrails-safety-four-lines-of-defence/">guardrails&lt;/a>.&lt;/li>
&lt;li>&lt;strong>Different versions per tenant&lt;/strong>: when the same &lt;code>prompt_id&lt;/code> needs variants per customer (i18n, branding, domain). Fork + override pattern.&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/six-stage-llmops-pipeline-architecture-and-deep-dive/">The six-stage LLMOps pipeline&lt;/a> — the master map where prompt versioning appears as a cross-cutting component in the &amp;ldquo;all stages&amp;rdquo; band. This post goes into the detail of that component.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/mlops-for-llms-landscape-2026/">MLOps specific to LLMs in 2026: the landscape&lt;/a> — the opening of the series with the tooling context and the structural differences from classic MLOps.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/evals-llm-layer-after-tracing/">Evals: the layer after tracing that decides whether your LLM performs or only looks like it&lt;/a> — the eval suites that hook into &lt;code>prompt_id&lt;/code> for the promotion gate described here.&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 &lt;code>gen_ai.*&lt;/code> semantic conventions and the trace context propagation that carry &lt;code>prompt_id+version&lt;/code> across every span.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/agentsight-llm-tracing-ebpf-zero-instrumentation/">AgentSight and the new LLM tracing&lt;/a> — how tracing observes prompts at runtime, including prompts composed in chains.&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> — the Tune+Retrain cycle produces adapters whose system prompt coexists with the versioning described here.&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&amp;rsquo;s prompt is versioned here too. A &amp;ldquo;minor&amp;rdquo; change in the rubric invalidates the κ calibration and, as a consequence, every gate that depends on the judge.&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 full OTel pipeline (Collector → Langfuse + Tempo + Prometheus) over which &lt;code>gen_ai.prompt.id+version&lt;/code> travels as a span attribute. Here we cover prompt versioning; there, the substrate that transports it.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/langfuse-inside-sorting-centre-bottleneck/">Langfuse from the inside: v3 architecture and the 10 backend knobs&lt;/a> — the prompt management layer described here lives in Postgres inside Langfuse&amp;rsquo;s six-service architecture; that post opens the box and explains how to operate it self-hosted.&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 system prompt is part of the perimeter to protect against leakage (OWASP LLM07). The versioning described here plus the GR output detectors are the two pieces that stop accidental prompt changes opening breaches in silence.&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 version 4 data model and the five score types.&lt;/li>
&lt;/ul>
&lt;h2 id="references">References&lt;/h2>
&lt;ul>
&lt;li>Langfuse documentation, &lt;em>Prompt Management&lt;/em>: &lt;a href="https://langfuse.com/docs/prompts/get-started">https://langfuse.com/docs/prompts/get-started&lt;/a>.&lt;/li>
&lt;li>MLflow 3.10 release notes, &lt;em>Prompts in Model Registry&lt;/em> (marzo 2026): &lt;a href="https://mlflow.org/releases/3.10">https://mlflow.org/releases/3.10&lt;/a>.&lt;/li>
&lt;li>OpenTelemetry, &lt;em>Semantic Conventions for Generative AI&lt;/em> (estables desde 1.36 de OTel): &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>Google Cloud, &lt;em>Prompt Management: Best Practices for Production LLM Systems&lt;/em> (2025).&lt;/li>
&lt;li>Chip Huyen, &lt;em>Designing Machine Learning Systems&lt;/em> — capítulo sobre model registry y prompt-as-artifact (2ª edición, marzo 2026).&lt;/li>
&lt;li>Eugene Yan, &lt;em>Prompt Engineering as Software Engineering&lt;/em> (blog, 2025).&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></channel></rss>