<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom"><channel><title>Self-Hosting on lo0 — Blog Técnico</title><link>https://blog.lo0.es/tags/self-hosting/</link><description>Recent content in Self-Hosting on lo0 — Blog Técnico</description><generator>Hugo -- gohugo.io</generator><language>es</language><lastBuildDate>Sat, 06 Jun 2026 06:00:00 +0200</lastBuildDate><atom:link href="https://blog.lo0.es/tags/self-hosting/index.xml" rel="self" type="application/rss+xml"/><item><title>Langfuse por dentro: el centro de clasificación que no debe convertirse en el cuello de botella que vino a observar</title><link>https://blog.lo0.es/posts/langfuse-self-hosting-arquitectura-tuning/</link><pubDate>Sat, 06 Jun 2026 06:00:00 +0200</pubDate><guid>https://blog.lo0.es/posts/langfuse-self-hosting-arquitectura-tuning/</guid><description>&lt;blockquote>
&lt;p>Este post cierra una trilogía de la capa &lt;strong>Observe&lt;/strong>: en &lt;a href="https://blog.lo0.es/posts/tracing-llm-otel-genai/">Tracing LLM con OpenTelemetry GenAI&lt;/a> se montó el pipeline &lt;code>SDK → Collector → backend&lt;/code> y se trató Langfuse como una caja negra que recibe spans; en &lt;a href="https://blog.lo0.es/posts/prompt-versioning-langfuse-mlflow/">Prompt versioning con Langfuse y MLflow&lt;/a> se usó su capa de prompt management. Aquí abrimos la caja: qué hay dentro de Langfuse, por qué v3 dejó de ser un monolito sobre Postgres, y cómo se opera para que aguante el tráfico de un cluster de inferencia sin convertirse en el problema.&lt;/p>
&lt;/blockquote>
&lt;h2 id="tldr">TL;DR&lt;/h2>
&lt;p>Langfuse v3 (estable desde diciembre de 2024) &lt;strong>no es una aplicación, son seis servicios&lt;/strong>: dos contenedores propios (Web y Worker) y cuatro dependencias de estado (Postgres, ClickHouse, Redis/Valkey y un blob store S3-compatible). El cambio arquitectónico clave respecto a v2 —que era un monolito Next.js sobre Postgres— es la &lt;strong>tubería de ingesta asíncrona&lt;/strong>: las trazas se reciben en lotes, se escriben &lt;em>inmediatamente&lt;/em> a S3, se encola solo una &lt;em>referencia&lt;/em> en Redis, y un Worker las ingiere a ClickHouse en segundo plano. Esto desacopla la velocidad de recepción (limitada solo por la latencia de escritura de Redis, ~1-5 ms) del coste de persistir y mergear en la base analítica. El resultado: el contenedor Web sostiene cientos de eventos por segundo sin que un pico bloquee al cliente que sirve la inferencia. Pero ese diseño solo rinde con los ajustes correctos. Este post cubre la arquitectura, su interacción con el resto del stack on-premise, y &lt;strong>diez knobs de backend&lt;/strong> —del batching a ClickHouse al sharding de colas, del modificador &lt;code>FINAL&lt;/code> a la higiene de las system log tables— que deciden el throughput real y el coste de almacenamiento. Y marca dónde el async esconde ventanas de pérdida de datos que conviene conocer antes de prometer &amp;ldquo;trazabilidad total&amp;rdquo;.&lt;/p>
&lt;h2 id="estás-aquí-observe-y-la-capa-que-sostiene-a-las-demás">Estás aquí: OBSERVE (y la capa que sostiene a las demás)&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="estás aquí: 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">Estás aquí: OBSERVE · el sustrato de almacenamiento que hace operable el tracing&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="la-analogía-el-centro-de-clasificación-postal">La analogía: el centro de clasificación postal&lt;/h2>
&lt;p>Imagina la oficina central de clasificación de correos de una gran ciudad en hora punta. Llegan camiones cargados de &lt;strong>sacas&lt;/strong> (lotes de cartas) a un ritmo que no para. Si el empleado de la ventanilla tuviera que &lt;strong>abrir cada saca, leer cada carta, decidir su destino y archivarla&lt;/strong> antes de aceptar el siguiente camión, la cola de camiones daría la vuelta a la manzana en diez minutos. Ningún centro de clasificación serio funciona así.&lt;/p>
&lt;p>Lo que hacen es &lt;strong>desacoplar la recepción del procesado&lt;/strong>:&lt;/p>
&lt;ol>
&lt;li>La &lt;strong>ventanilla de recepción&lt;/strong> acepta la saca, le pone un sello de acuse, la deja en un &lt;strong>casillero&lt;/strong> del almacén y suelta un &lt;strong>ticket&lt;/strong> en una cinta transportadora. Tiempo por saca: segundos. La ventanilla nunca se bloquea.&lt;/li>
&lt;li>Más atrás, en la &lt;strong>sala de clasificación&lt;/strong>, un equipo de operarios va cogiendo tickets de la cinta, recupera la saca de su casillero, la abre, clasifica las cartas y las archiva en el &lt;strong>archivo permanente&lt;/strong> —ordenado, indexado, consultable.&lt;/li>
&lt;/ol>
&lt;p>Langfuse v3 es exactamente este centro de clasificación:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Centro postal&lt;/th>
&lt;th>Langfuse v3&lt;/th>
&lt;th>Función&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Ventanilla de recepción&lt;/td>
&lt;td>Contenedor &lt;strong>Web&lt;/strong> (endpoint de ingesta)&lt;/td>
&lt;td>Acepta lotes de eventos, da acuse inmediato (HTTP 207)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Almacén de casilleros&lt;/td>
&lt;td>&lt;strong>S3 / Blob store&lt;/strong> (MinIO on-prem)&lt;/td>
&lt;td>Guarda la saca cruda (el evento completo)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Ticket en la cinta&lt;/td>
&lt;td>&lt;strong>Redis / Valkey&lt;/strong> (cola BullMQ)&lt;/td>
&lt;td>Solo la &lt;em>referencia&lt;/em> al objeto en S3, no el contenido&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Sala de clasificación&lt;/td>
&lt;td>Contenedor &lt;strong>Worker&lt;/strong>&lt;/td>
&lt;td>Coge tickets, lee S3, transforma y archiva&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Archivo permanente indexado&lt;/td>
&lt;td>&lt;strong>ClickHouse&lt;/strong> (OLAP)&lt;/td>
&lt;td>Trazas, observaciones y scores, consultables por proyecto+tiempo&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Registro administrativo&lt;/td>
&lt;td>&lt;strong>Postgres&lt;/strong> (OLTP)&lt;/td>
&lt;td>Usuarios, proyectos, API keys, prompts, datasets, config&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>La tesis de todo el post se deriva de esta analogía: &lt;strong>el valor de Langfuse está en que la ventanilla nunca bloquee al cliente que sirve la inferencia&lt;/strong>. Una herramienta de observabilidad que añade latencia o caídas a la ruta de servir tokens es peor que no tener observabilidad —porque degrada justo el sistema que pretendía cuidar. Todo el diseño de v3, y todos los knobs de este post, existen para mantener esa promesa bajo carga.&lt;/p>
&lt;h2 id="el-mecanismo-en-sí-seis-servicios-dos-planos">El mecanismo en sí: seis servicios, dos planos&lt;/h2>
&lt;p>Langfuse v3 separa dos planos que en v2 estaban fundidos:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Plano de ingesta y consulta&lt;/strong> (los dos contenedores propios, &lt;em>stateless&lt;/em>, escalables horizontalmente): Web y Worker.&lt;/li>
&lt;li>&lt;strong>Plano de estado&lt;/strong> (cuatro dependencias, cada una con su perfil de carga): Postgres (OLTP transaccional), ClickHouse (OLAP analítico), Redis/Valkey (cola + caché), Blob store (objetos crudos).&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="Arquitectura de seis servicios de 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 · seis servicios, dos planos&lt;/text>
&lt;!-- Clientes -->
&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;!-- Plano stateless -->
&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">Plano stateless (escala horizontal)&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">ingesta + 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">clasifica → 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">cola BullMQ (refs) + caché API keys/prompts&lt;/text>
&lt;!-- Plano de estado -->
&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">Plano de estado&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">eventos crudos + 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;!-- flujo ingesta -->
&lt;p>&lt;text x="510" y="356" text-anchor="middle" font-family="sans-serif" font-size="10.5" font-weight="600" fill="#3c8c54">Ruta de ingesta (asíncrona)&lt;/text>
&lt;text x="510" y="376" text-anchor="middle" font-family="sans-serif" font-size="10" fill="#444">① Web escribe evento → S3 ② Web encola ref → Redis&lt;/text>
&lt;text x="510" y="394" text-anchor="middle" font-family="sans-serif" font-size="10" fill="#444">③ Worker saca ref de Redis ④ lee S3 → ⑤ inserta en ClickHouse&lt;/text>
&lt;text x="510" y="416" text-anchor="middle" font-family="sans-serif" font-size="10" fill="#444">UI/API: Web lee de ClickHouse (traces) + Postgres (config)&lt;/text>&lt;/p>
&lt;!-- conexiones -->
&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>Lo que hay que retener de este diagrama:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Web y Worker son intercambiables y stateless.&lt;/strong> No guardan nada localmente. Puedes correr 1 o 20 réplicas de cada uno; el estado vive en las cuatro dependencias. Esto es lo que permite escalar por carga sin coreografías.&lt;/li>
&lt;li>&lt;strong>Redis nunca lleva el contenido del evento, solo la referencia&lt;/strong> al objeto en S3. Por eso Redis aguanta el pico: una escritura de Redis es ~1-5 ms y mueve bytes, no kilobytes. El cuello de botella del contenedor Web es, literalmente, &lt;em>la velocidad de escritura de Redis&lt;/em>.&lt;/li>
&lt;li>&lt;strong>Postgres y ClickHouse tienen perfiles opuestos.&lt;/strong> Postgres es OLTP: muchas lecturas/escrituras pequeñas y transaccionales (¿esta API key es válida?, ¿qué versión tiene el label &lt;code>production&lt;/code>?). ClickHouse es OLAP: pocas escrituras enormes en batch y consultas analíticas sobre miles de millones de filas (dame el p95 de TTFT del proyecto X en los últimos 7 días). Meter trazas en Postgres —lo que hacía v2— funciona hasta que no funciona: a volumen de producción, Postgres se ahoga en una carga para la que no está diseñado. Ese fue el motivo del rediseño.&lt;/li>
&lt;/ul>
&lt;h2 id="el-flujo-de-ingesta-paso-a-paso-y-las-matemáticas-del-desacoplo">El flujo de ingesta paso a paso (y las matemáticas del desacoplo)&lt;/h2>
&lt;p>El corazón del diseño es la ruta de ingesta. Vista en detalle, una request &lt;code>POST /api/public/ingestion&lt;/code> con un lote de eventos hace esto:&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="Secuencia de ingesta asíncrona de Langfuse">
&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">Cliente&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 lote eventos&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">① escribe evento crudo&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">② encola REFERENCIA (no el evento)&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 (acuse) ~ms&lt;/text>
&lt;p>&lt;text x="165" y="160" text-anchor="middle" font-family="sans-serif" font-size="9" font-style="italic" fill="#999">— el cliente ya siguió con su trabajo —&lt;/text>&lt;/p>
&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 saca 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">④ lee evento de 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 + flush batch → 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, fuera de la ruta del cliente&lt;/text>
&lt;/svg>
&lt;/div>
&lt;p>El punto matemático es el &lt;strong>acuse temprano (early ACK)&lt;/strong>. La latencia que el cliente percibe al enviar trazas es:&lt;/p>
&lt;p>$$ t_{\text{cliente}} = t_{\text{S3 write}} + t_{\text{Redis enqueue}} \approx 10\text{–}40,\text{ms} $$&lt;/p>
&lt;p>mientras que el coste real de persistir —leer S3, transformar, mergear contra la versión previa, insertar en ClickHouse, dejar que los background merges compacten— ocurre &lt;strong>fuera de esa ruta&lt;/strong>, en el Worker, y puede tardar cientos de ms o segundos sin que al cliente le importe. El desacoplo convierte un sistema cuyo throughput estaría limitado por la velocidad de ClickHouse en uno limitado por la velocidad de Redis. Y Redis, en hardware modesto, sostiene del orden de &lt;strong>50.000 operaciones/segundo&lt;/strong>.&lt;/p>
&lt;p>Esto tiene una consecuencia de dimensionado importante. Si tu carga de inferencia genera $E$ eventos/segundo (un chat con RAG + 2 tool calls produce fácilmente 6-10 spans = eventos por petición), el contenedor Web los absorbe mientras $E \ll 50.000$. El Worker, en cambio, escala con el coste de &lt;em>procesar&lt;/em>: ese es el componente que hay que vigilar y replicar, y el primer knob del post.&lt;/p>
&lt;blockquote>
&lt;p>&lt;strong>Escepticismo honesto.&lt;/strong> El early ACK tiene una cara B: entre el HTTP 207 y la persistencia en ClickHouse hay una &lt;strong>ventana de pérdida potencial&lt;/strong>. Si el evento está en S3 y la referencia en Redis, y Redis se cae sin persistencia (AOF/RDB) antes de que el Worker procese, la referencia se pierde —el dato sigue en S3 pero ya nadie lo reclama. Más sutil: el Worker bufferiza escrituras a ClickHouse en memoria y las hace flush por lotes; un crash del Worker con el buffer lleno pierde ese lote. Existe un &lt;a href="https://github.com/langfuse/langfuse/issues/13468">bug reportado&lt;/a> donde el &lt;code>ClickhouseWriter&lt;/code> descarta filas tras agotar reintentos de flush &lt;strong>sin dead-letter queue&lt;/strong>. Para observabilidad esto suele ser tolerable (perder el 0,01 % de las trazas no rompe nada). Para &lt;em>auditoría regulatoria&lt;/em> —donde la traza es evidencia— no lo es, y conviene tratar Langfuse como &amp;ldquo;best-effort&amp;rdquo; y no como libro contable. Volveremos sobre esto en el cierre.&lt;/p>
&lt;/blockquote>
&lt;h2 id="interacción-con-el-resto-del-stack-langfuse-en-el-cluster-4h100-de-ejemplo">Interacción con el resto del stack: Langfuse en el cluster 4×H100 de ejemplo&lt;/h2>
&lt;p>Langfuse no vive aislado. En el &lt;a href="https://blog.lo0.es/posts/siete-capas-stack-inferencia-llm-on-premise/">stack de siete capas&lt;/a> ocupa la capa de observabilidad LLM-aware, y se relaciona con casi todas las demás. Sobre el cluster genérico de referencia que usamos en todo el blog —&lt;strong>4×H100 SXM 80 GB (320 GB VRAM agregada), NVLink, 640 GB RAM de sistema, NVMe-oF, red 25/100 GbE&lt;/strong>— el flujo de telemetría es así:&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="Interacción de Langfuse con el stack de inferencia sobre cluster 4xH100">
&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">Plano de datos vs plano de telemetría · cluster 4×H100 SXM&lt;/text>
&lt;!-- Plano de datos -->
&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">Plano de datos (sirve tokens · ruta caliente)&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 / Agente&lt;/text>
&lt;text x="135" y="115" text-anchor="middle" font-family="sans-serif" font-size="9" fill="#444">SDK Langfuse / OTel&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">Gateway L7&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">LLM general · 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 centro -->
&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;!-- Plano telemetria -->
&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">Telemetría (fría)&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">nodo CPU dedicado&lt;/text>
&lt;text x="740" y="191" text-anchor="middle" font-family="sans-serif" font-size="8.5" font-style="italic" fill="#777">(fuera de las GPU)&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">spans infra&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;!-- flechas datos -->
&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 hacia 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">spans gen_ai.*&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 hacia 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">trazas LLM&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>Tres ideas de esta topología:&lt;/p>
&lt;ol>
&lt;li>
&lt;p>&lt;strong>Langfuse recibe del OTel Collector, no de la aplicación directamente&lt;/strong> (en el patrón recomendado). El SDK de la app o vLLM emiten spans con las semantic conventions &lt;code>gen_ai.*&lt;/code>; el Collector hace &lt;code>batch&lt;/code>, &lt;code>tail-sampling&lt;/code> (preserva el 100 % de errores y latencias altas, muestrea el resto) y enriquece con atributos propios (&lt;code>tenant_id&lt;/code>, &lt;code>priority_tier&lt;/code>); y &lt;em>reparte&lt;/em>: las trazas LLM van a Langfuse, los spans de infraestructura a Tempo, las métricas (DCGM de GPU, métricas de vLLM) a Prometheus. Langfuse es &lt;strong>un exporter más&lt;/strong>, no el único destino. Esto está cubierto en detalle en &lt;a href="https://blog.lo0.es/posts/tracing-llm-otel-genai/">el post de tracing OTel&lt;/a>.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;strong>Langfuse corre fuera de las GPU.&lt;/strong> Es un consumidor de CPU, RAM, disco y red —ClickHouse quiere memoria, MinIO quiere disco, Redis quiere CPU para networking— pero &lt;strong>no toca la VRAM&lt;/strong>. En el cluster 4×H100, Langfuse vive en un nodo de CPU (o en los nodos GPU pero con &lt;code>nodeSelector&lt;/code>/&lt;code>taints&lt;/code> que lo mantengan lejos de los pods de vLLM). Mezclar ClickHouse con vLLM en el mismo nodo sin límites de recursos es pedir que un pico de ingesta robe ancho de banda de memoria a la inferencia. Aislamiento por diseño.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;strong>La ruta de telemetría es &amp;ldquo;fría&amp;rdquo; y la de datos es &amp;ldquo;caliente&amp;rdquo;.&lt;/strong> El plano de datos (izquierda) sirve tokens con presupuesto de latencia de milisegundos; el plano de telemetría (derecha) tolera segundos. El acuse temprano de la ingesta es lo que mantiene estos dos relojes separados: la app no espera a que Langfuse archive nada para devolver la respuesta al usuario.&lt;/p>
&lt;/li>
&lt;/ol>
&lt;h2 id="los-10-knobs-de-backend-que-más-mueven-la-aguja">Los 10 knobs de backend que más mueven la aguja&lt;/h2>
&lt;p>Estos son, por orden aproximado de impacto/frecuencia, los ajustes que deciden si tu Langfuse self-hosted ingiere 50 eventos/s o 5.000, y si tu disco crece de forma sostenible o explota en tres semanas. Todos son variables de entorno o config que se inyectan en los contenedores &lt;strong>Web y Worker&lt;/strong> (salvo los de ClickHouse, que van en su config server-side). El detalle canónico está en la &lt;a href="https://langfuse.com/self-hosting/configuration/scaling">doc de scaling de Langfuse&lt;/a>.&lt;/p>
&lt;h3 id="knob-1--escalar-el-worker-por-carga-la-primera-palanca-siempre">Knob 1 — Escalar el Worker por carga (la primera palanca, siempre)&lt;/h3>
&lt;p>El Worker es el componente que se satura primero, porque es quien hace el trabajo caro: leer S3, transformar, mergear, insertar en ClickHouse. La regla operativa de Langfuse es simple: &lt;strong>un contenedor Worker de 2 CPU por encima del 50 % de uso de CPU está saturado&lt;/strong>; añade réplicas. Mejor aún que la CPU, el Worker publica vía statsd la métrica &lt;code>langfuse.queue.ingestion.length&lt;/code> (longitud de la cola de ingesta), que es la señal directa para autoescalar: si la cola crece sin drenar, faltan 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"># El autoscaler ideal mira la profundidad de cola, no solo 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 sobre la métrica statsd → 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"># si la cola pasa de 10k refs, escala&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>En despliegues AWS existe &lt;code>ENABLE_AWS_CLOUDWATCH_METRIC_PUBLISHING=true&lt;/code> para empujar estas métricas a CloudWatch. On-premise, el camino es statsd → Prometheus → KEDA, encajado con el &lt;a href="https://blog.lo0.es/posts/autoscaling-llm-kubernetes-keda/">autoscaling en Kubernetes con KEDA&lt;/a> que ya cubrimos para vLLM. &lt;strong>Empieza siempre por aquí&lt;/strong>: la mayoría de los problemas de &amp;ldquo;Langfuse va lento&amp;rdquo; son simplemente Workers insuficientes, no afinado fino.&lt;/p>
&lt;h3 id="knob-2--separar-el-deployment-de-ingesta-del-de-ui">Knob 2 — Separar el deployment de ingesta del de UI&lt;/h3>
&lt;p>Cuando la ingesta va muy cargada, las consultas de la UI y la API pública se vuelven lentas porque comparten el mismo contenedor Web. La solución es &lt;strong>partir langfuse-web en dos deployments idénticos&lt;/strong> y enrutar por path: todo lo que sea &lt;code>/api/public/ingestion*&lt;/code>, &lt;code>/api/public/media*&lt;/code> y &lt;code>/api/public/otel*&lt;/code> va al deployment de ingesta; el resto (UI, API de lectura) al de interfaz.&lt;/p>
&lt;pre tabindex="0">&lt;code># Regla de Ingress / gateway
location ~ ^/api/public/(ingestion|media|otel) {
proxy_pass http://langfuse-web-ingest; # réplicas dedicadas a escribir
}
location / {
proxy_pass http://langfuse-web-ui; # réplicas dedicadas a leer
}
&lt;/code>&lt;/pre>&lt;p>Es la misma idea que la separación read/write de cualquier sistema con cargas mixtas: que una tormenta de escrituras no deje sin recursos a quien intenta &lt;em>mirar&lt;/em> el dashboard justo durante el incidente —que es precisamente cuando más lo necesitas.&lt;/p>
&lt;h3 id="knob-3--batching-de-escrituras-a-clickhouse-interval--batch-size">Knob 3 — Batching de escrituras a ClickHouse (interval + batch size)&lt;/h3>
&lt;p>ClickHouse odia las inserciones pequeñas y frecuentes: cada &lt;code>INSERT&lt;/code> crea una &lt;em>part&lt;/em> en disco que luego hay que mergear, y miles de inserts diminutos generan miles de parts y una tormenta de background merges que satura el disco. La defensa es &lt;strong>acumular en un buffer en memoria del Worker y hacer flush por lotes grandes&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: menos flushes, lotes más grandes → menos parts, menos 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"># sube p.ej. a 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"># sube si hay throughput&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Subir el intervalo y el tamaño de lote &lt;strong>reduce la frecuencia de flushes&lt;/strong> y mejora el throughput sostenido. El trade-off es directo y hay que entenderlo: lotes más grandes y menos frecuentes significan &lt;strong>más datos en el buffer volátil del Worker&lt;/strong>, es decir, una ventana de pérdida mayor si el Worker se cae (knob acoplado al escepticismo del cierre). Langfuse además usa &lt;code>async_insert&lt;/code> de ClickHouse, que acumula server-side antes de confirmar; suma otra capa de buffering a tener presente.&lt;/p>
&lt;h3 id="knob-4--saltar-la-lectura-previa-a-clickhouse-en-la-ingesta">Knob 4 — Saltar la lectura previa a ClickHouse en la ingesta&lt;/h3>
&lt;p>Por defecto, al ingerir un evento el Worker &lt;strong>lee de ClickHouse el evento existente y lo mergea&lt;/strong> con lo entrante (necesario cuando los SDKs legacy mandan eventos parciales: un &lt;code>start&lt;/code>, luego un &lt;code>end&lt;/code>, luego un &lt;code>update&lt;/code> de la misma observación). Esa lectura por evento carga ClickHouse en la ruta de escritura y limita el throughput total.&lt;/p>
&lt;p>Si tus proyectos no vienen migrados de una versión antigua —porque el histórico completo ya vive en S3— puedes &lt;strong>desactivar esa lectura&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"># Fecha anterior a la creación de tu primer proyecto&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>Con los SDKs modernos de Langfuse o con ingesta vía OpenTelemetry, esto no te afecta negativamente y quita una lectura por evento. Aviso de la propia doc: si combinas esto con reglas de borrado (lifecycle) agresivas en S3 más updates tardíos de eventos, puedes generar duplicados en el histórico. Conócelo antes de activarlo.&lt;/p>
&lt;h3 id="knob-5--concurrencia-de-escritura-a-s3blob-storage">Knob 5 — Concurrencia de escritura a S3/Blob storage&lt;/h3>
&lt;p>En escenarios de alto throughput, el cliente de S3 puede &lt;strong>agotar sus sockets&lt;/strong> y empezar a encolar y throttlear escrituras. El síntoma es inconfundible en los logs del contenedor Web que procesa ingesta:&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>…acompañado de una subida de memoria en ese contenedor (las requests encoladas se acumulan en RAM). La cura es subir el límite de escrituras concurrentes desde su default de 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"># sube gradualmente desde 50&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Cada socket adicional tiene un pequeño coste de memoria, así que el consejo oficial es subirlo &lt;strong>de forma gradual&lt;/strong> observando el comportamiento, no saltar a 1000 de golpe.&lt;/p>
&lt;h3 id="knob-6--sharding-de-colas-redis--concurrencia-por-shard">Knob 6 — Sharding de colas Redis + concurrencia &lt;em>por shard&lt;/em>&lt;/h3>
&lt;p>Si Redis pasa del 90 % de CPU, primero lo obvio: instancia con &lt;strong>al menos 4 CPU&lt;/strong> (para que Redis reparta networking y tareas de fondo en cores distintos) y &lt;strong>Redis Cluster mode&lt;/strong> activado. Si aún así la CPU no baja, se pueden &lt;strong>shardear las colas&lt;/strong> que usa Langfuse:&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"># Avanzado: solo si Redis va ahogado y ya hiciste lo anterior&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× nº de shards del cluster Redis&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"># La concurrencia cuenta POR SHARD; objetivo ~20 por 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>Dos trampas que la doc subraya y conviene tatuarse: &lt;strong>una vez shardeas, no reduzcas el número de shards&lt;/strong> (rompe el reparto); y la concurrencia se cuenta &lt;strong>por shard&lt;/strong>, no global —si tienes 10 shards y quieres concurrencia 20 por worker, pon &lt;code>2&lt;/code>, no &lt;code>20&lt;/code>. Es un knob avanzado: la mayoría de despliegues on-premise nunca lo necesitan.&lt;/p>
&lt;h3 id="knob-7--el-modificador-final-para-proyectos-solo-otel">Knob 7 — El modificador &lt;code>FINAL&lt;/code> para proyectos solo-OTel&lt;/h3>
&lt;p>Langfuse guarda las observaciones en un &lt;code>ReplacingMergeTree&lt;/code> de ClickHouse y, por defecto, añade el modificador &lt;code>FINAL&lt;/code> a las consultas de la API para que gane la última versión de cada fila en tiempo de lectura. &lt;code>FINAL&lt;/code> es necesario cuando la ingesta produce varias versiones de la misma observación (los SDKs legacy con sus eventos &lt;code>start&lt;/code>/&lt;code>end&lt;/code>/&lt;code>update&lt;/code>), pero &lt;strong>añade trabajo de merge en cada lectura y la ralentiza&lt;/strong>.&lt;/p>
&lt;p>Los proyectos que ingieren &lt;strong>exclusivamente por OpenTelemetry&lt;/strong> escriben cada observación como una fila inmutable única, así que &lt;code>FINAL&lt;/code> les sobra:&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"># Recomendado en despliegues mixtos: per-project, marca en Redis con TTL 24h&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"># Solo si TODOS los proyectos son OTel-only: global, sin lookup en Redis&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>Como en el cluster de ejemplo la instrumentación es 100 % OTel (&lt;code>gen_ai.*&lt;/code> vía Collector), este knob es &lt;strong>dinero gratis en latencia de lectura del dashboard&lt;/strong>. Cuidado con la versión global: no la actives si algún proyecto sigue usando ingesta legacy, o las lecturas pueden devolver filas duplicadas o stale.&lt;/p>
&lt;h3 id="knob-8--separar-lecturas-analíticas-del-path-de-escritura-compute-compute">Knob 8 — Separar lecturas analíticas del path de escritura (compute-compute)&lt;/h3>
&lt;p>Las consultas pesadas del dashboard (percentiles sobre millones de spans) compiten con los inserts de ingesta y con los background merges sobre el &lt;em>mismo&lt;/em> ClickHouse. Si tu despliegue soporta &lt;strong>separación compute-compute&lt;/strong> (ClickHouse Cloud o BYOC), puedes enrutar las lecturas a un grupo de cómputo de solo-lectura:&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, migraciones, ingesta&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"># lecturas UI + API pública&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;strong>Matiz crítico para on-premise&lt;/strong> —y aquí toca ser escéptico con la utilidad de este knob en nuestro contexto: en un ClickHouse &lt;strong>single-node&lt;/strong> o en un cluster self-managed sin separación de cómputo, esta variable &lt;strong>no aporta nada&lt;/strong>, porque el endpoint de lectura sería el mismo que el de escritura. Es un knob para arquitecturas cloud con almacenamiento separado del cómputo. En un cluster 4×H100 on-premise con ClickHouse en un nodo, la alternativa real es &lt;strong>escalar ClickHouse verticalmente&lt;/strong> (la doc recomienda ≥16 GiB de RAM para deployments grandes; ClickHouse escala vertical bien) y asegurar que &lt;strong>todas las consultas filtran por &lt;code>projectId&lt;/code> y tiempo&lt;/strong>, que es como están indexadas las tablas. Sin filtro temporal, hasta el ClickHouse más gordo sufre.&lt;/p>
&lt;h3 id="knob-9--retención-de-datos-ttl-en-clickhouse--lifecycle-en-s3">Knob 9 — Retención de datos: TTL en ClickHouse + lifecycle en S3&lt;/h3>
&lt;p>El disco es el coste que crece solo. Las trazas LLM cargan inputs y outputs enteros (a veces prompts de decenas de KB), y ClickHouse además acumula sus propias tablas de sistema. La palanca de primer orden es una &lt;strong>política de retención&lt;/strong> que borra nightly trazas, observaciones, scores y media más viejos que N días, coordinando ClickHouse y blob storage. Donde la feature de retención no esté disponible, se hace a mano:&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 sobre las tablas de tracing
&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, p.ej. 30 días para el bucket de eventos crudos
# ¡OJO! NO apliques retención al bucket de MEDIA:
# - rompe los ficheros referenciados en trazas
# - rompe futuras subidas (el estado se trackea por hash en Postgres)
&lt;/code>&lt;/pre>&lt;p>Dos parámetros de operación que evitan sustos en borrados grandes:&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; súbelo si los borrados expiran&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># ClickHouse 25.7+: menos presión de mutaciones en borrados masivos&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>La regla mental: &lt;strong>retención corta para eventos crudos&lt;/strong> (S3, 30 días suele bastar — son recuperables/recomputables), &lt;strong>retención por valor de negocio para las tablas de ClickHouse&lt;/strong> (90 días, 180, lo que pida compliance), y &lt;strong>nunca toques el bucket de media con lifecycle ciego&lt;/strong>.&lt;/p>
&lt;h3 id="knob-10--higiene-de-las-system-log-tables-de-clickhouse-el-asesino-silencioso-del-disco">Knob 10 — Higiene de las &lt;em>system log tables&lt;/em> de ClickHouse (el asesino silencioso del disco)&lt;/h3>
&lt;p>Este es el knob que nadie configura y que llena el disco sin que aparezca en ninguna métrica de Langfuse, porque &lt;strong>no es dato de Langfuse&lt;/strong>: son las tablas de sistema del propio ClickHouse (&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>). Por defecto &lt;strong>no tienen TTL&lt;/strong>, y el query profiler escribe en &lt;code>system.trace_log&lt;/code> continuamente. En un ClickHouse con tráfico, estas tablas pueden &lt;strong>dominar el uso de disco&lt;/strong> mientras tú buscas el problema en tus trazas. Langfuse no lee de ellas, así que se pueden recortar sin miedo. Dos opciones:&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;!-- Opción A — desactivar las que Langfuse nunca lee
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c"> (fichero en /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;!-- Mantén query_log, part_log y error_log: útiles para debug y pequeños --&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">-- Opción B — TTL agresivo + apagar el profiler, si quieres conservarlas para debug
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1">-- (en 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">-- repetir para cada tabla de log a capar
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Para identificar qué tabla se está comiendo el disco, la consulta de oro:&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>Si solo te llevas un knob de este post a tu primer despliegue real, que sea este: la diferencia entre un ClickHouse que crece 2 GB/día de datos útiles y uno que crece 20 GB/día de logs de sistema que nadie mira.&lt;/p>
&lt;h3 id="tabla-resumen-de-los-10-knobs">Tabla resumen de los 10 knobs&lt;/h3>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>#&lt;/th>
&lt;th>Knob&lt;/th>
&lt;th>Variable / acción&lt;/th>
&lt;th>Cuándo&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>1&lt;/td>
&lt;td>Escalar Worker&lt;/td>
&lt;td>réplicas por CPU&amp;gt;50 % / &lt;code>langfuse.queue.ingestion.length&lt;/code>&lt;/td>
&lt;td>siempre, primero&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>2&lt;/td>
&lt;td>Separar ingesta/UI&lt;/td>
&lt;td>enrutar &lt;code>/ingestion*&lt;/code>,&lt;code>/media*&lt;/code>,&lt;code>/otel*&lt;/code> a réplica dedicada&lt;/td>
&lt;td>UI lenta bajo carga&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>3&lt;/td>
&lt;td>Batching a 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>throughput alto&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>4&lt;/td>
&lt;td>Saltar lectura previa CH&lt;/td>
&lt;td>&lt;code>LANGFUSE_SKIP_INGESTION_CLICKHOUSE_READ_MIN_PROJECT_CREATE_DATE&lt;/code>&lt;/td>
&lt;td>proyectos no migrados&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>5&lt;/td>
&lt;td>Concurrencia S3&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>Sharding colas Redis&lt;/td>
&lt;td>&lt;code>LANGFUSE_*_QUEUE_SHARD_COUNT&lt;/code> + &lt;code>*_CONCURRENCY&lt;/code> (por shard)&lt;/td>
&lt;td>Redis CPU &amp;gt;90 %&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>7&lt;/td>
&lt;td>Quitar &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>instrumentación 100 % OTel&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>8&lt;/td>
&lt;td>Read/write split CH&lt;/td>
&lt;td>&lt;code>CLICKHOUSE_READ_ONLY_URL&lt;/code> (solo cloud/BYOC)&lt;/td>
&lt;td>compute-compute disponible&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>9&lt;/td>
&lt;td>Retención + TTL&lt;/td>
&lt;td>TTL en CH + lifecycle S3 + &lt;code>LANGFUSE_CLICKHOUSE_DELETION_TIMEOUT_MS&lt;/code>&lt;/td>
&lt;td>siempre (coste disco)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>10&lt;/td>
&lt;td>Higiene system logs CH&lt;/td>
&lt;td>&lt;code>&amp;lt;trace_log remove=&amp;quot;1&amp;quot;/&amp;gt;&lt;/code> o TTL agresivo&lt;/td>
&lt;td>siempre (disco oculto)&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;h2 id="cómo-maximizar-langfuse-en-el-cluster-4h100-de-ejemplo">Cómo maximizar Langfuse en el cluster 4×H100 de ejemplo&lt;/h2>
&lt;p>Con la arquitectura y los knobs claros, este es un dimensionado concreto para sacar el máximo a Langfuse sobre el cluster genérico de referencia (&lt;strong>4×H100 SXM, 320 GB VRAM, 640 GB RAM, NVMe-oF, 25/100 GbE&lt;/strong>), sin robar un solo GB de VRAM a la inferencia.&lt;/p>
&lt;h3 id="reparto-de-componentes">Reparto de componentes&lt;/h3>
&lt;p>Langfuse es &lt;strong>100 % carga de CPU/RAM/disco/red&lt;/strong>, así que su sitio natural es &lt;strong>fuera de los nodos GPU&lt;/strong> o, si se cohabita, con &lt;code>taints&lt;/code>/&lt;code>nodeSelector&lt;/code> que lo confinen lejos de los pods de vLLM. Reparto sugerido:&lt;/p>
&lt;pre tabindex="0">&lt;code>nodo-cpu-01 (control + observabilidad, sin GPU)
├── langfuse-web-ingest ×3 (2 CPU / 4 GiB c/u) ← ingesta, escala con carga
├── langfuse-web-ui ×2 (2 CPU / 4 GiB c/u) ← dashboard/API lectura
├── langfuse-worker ×4 (2 CPU / 4 GiB c/u) ← el que más escala
├── redis/valkey ×1 (4 CPU / 4 GiB, cluster mode)
└── postgres ×1 (2 CPU / 8 GiB, réplica para HA)
nodo-storage-01 (estado pesado, NVMe local)
├── clickhouse ×1 (8 CPU / 32 GiB / NVMe) ← ≥16 GiB es el mínimo; 32 holgado
└── minio (S3) ×1 (4 CPU / 8 GiB / HDD+NVMe cache)
nodo-gpu-01..02 (4×H100 SXM cada uno) → SOLO inferencia
└── vLLM, embeddings, reranker, guardrails (emiten spans, no alojan Langfuse)
&lt;/code>&lt;/pre>&lt;h3 id="dimensionado-por-carga-real">Dimensionado por carga real&lt;/h3>
&lt;p>Pongamos números a una carga de ejemplo. Supongamos el cluster sirviendo &lt;strong>300 peticiones/segundo&lt;/strong> de chat-con-RAG, donde cada petición genera del orden de &lt;strong>8 spans&lt;/strong> (request, retrieval, rerank, 2× tool, guardrail in, llm, guardrail out):&lt;/p>
&lt;p>$$ E = 300,\tfrac{\text{req}}{\text{s}} \times 8,\tfrac{\text{spans}}{\text{req}} = 2.400\ \text{eventos/s} $$&lt;/p>
&lt;p>Frente al techo de Redis (~50.000 ops/s), $E = 2.400$ deja la ventanilla de recepción al &lt;strong>~5 % de su capacidad&lt;/strong>: holgura enorme. El componente a vigilar es el Worker. Con un objetivo de ~20 de concurrencia por Worker y lotes de 10.000 eventos cada ~1-2 s, 4 Workers drenan 2.400 ev/s con margen; la métrica &lt;code>langfuse.queue.ingestion.length&lt;/code> debe mantenerse plana cerca de cero. Si crece, el knob 1 (más Workers) es la respuesta antes que cualquier afinado.&lt;/p>
&lt;p>&lt;strong>Tail-sampling es el multiplicador que cambia la economía.&lt;/strong> Si el Collector preserva el 100 % de errores/latencias-altas pero muestrea el tráfico normal al, digamos, 10 %, los 2.400 ev/s que &lt;em>almacenas&lt;/em> en ClickHouse bajan a ~240-300 ev/s efectivos sin perder la señal que importa. La regla: &lt;strong>muestrea en el Collector, no en Langfuse&lt;/strong> —Langfuse debe recibir ya filtrado lo que merece persistirse. Esto está desarrollado en &lt;a href="https://blog.lo0.es/posts/tracing-llm-otel-genai/">el post de tracing OTel&lt;/a>; aquí basta con notar que el sampling de aguas arriba es, de facto, el knob 0 que multiplica a todos los demás.&lt;/p>
&lt;h3 id="estimación-de-almacenamiento">Estimación de almacenamiento&lt;/h3>
&lt;p>Una observación LLM con input+output completos pesa, comprimida en ClickHouse, del orden de &lt;strong>1-3 KB&lt;/strong> (ClickHouse comprime texto muy bien, 5-10×). Con sampling al 10 % sobre 2.400 ev/s:&lt;/p>
&lt;p>$$ 240,\tfrac{\text{ev}}{\text{s}} \times 2,\text{KB} \times 86.400,\tfrac{\text{s}}{\text{día}} \approx 41\ \text{GB/día (cruda)} ;\xrightarrow{\text{compresión}}; \sim 5\text{–}8\ \text{GB/día en CH} $$&lt;/p>
&lt;p>A 90 días de retención (knob 9), el archivo permanente se estabiliza en torno a &lt;strong>500-700 GB en ClickHouse&lt;/strong> —cómodo en el NVMe del nodo de storage— más los eventos crudos en MinIO con lifecycle de 30 días. &lt;strong>Sin&lt;/strong> la higiene de system logs (knob 10), súmale fácilmente otro tanto de basura que nadie consulta. Los dos knobs de disco juntos son la diferencia entre planificar storage una vez al año o pelearte con el disco lleno cada mes.&lt;/p>
&lt;h3 id="checklist-de-máximo-aprovechamiento">Checklist de &amp;ldquo;máximo aprovechamiento&amp;rdquo;&lt;/h3>
&lt;ol>
&lt;li>&lt;strong>Sampling en el Collector&lt;/strong> (tail: 100 % errores + N % normal) — antes de tocar nada en Langfuse.&lt;/li>
&lt;li>&lt;strong>Workers escalados por longitud de cola&lt;/strong> vía KEDA (knob 1), no fijos.&lt;/li>
&lt;li>&lt;strong>Ingesta separada de UI&lt;/strong> (knob 2) para que el dashboard responda durante incidentes.&lt;/li>
&lt;li>&lt;strong>&lt;code>SKIP_FINAL_FOR_OTEL_PROJECTS&lt;/code>&lt;/strong> activo (knob 7) porque la instrumentación es 100 % OTel.&lt;/li>
&lt;li>&lt;strong>Batching CH generoso&lt;/strong> (knob 3) ajustado al throughput, asumiendo la ventana de pérdida.&lt;/li>
&lt;li>&lt;strong>Retención + TTL + higiene de system logs&lt;/strong> (knobs 9 y 10) configurados el día 1, no cuando el disco grite.&lt;/li>
&lt;li>&lt;strong>ClickHouse con ≥16 GiB y todas las queries filtrando por &lt;code>projectId&lt;/code>+tiempo&lt;/strong> (knob 8 en su versión on-premise: escala vertical).&lt;/li>
&lt;li>&lt;strong>Langfuse aislado de las GPU&lt;/strong> por &lt;code>taints&lt;/code>/&lt;code>nodeSelector&lt;/code>: ni un MB de VRAM, ni contención de ancho de banda de memoria con vLLM.&lt;/li>
&lt;/ol>
&lt;h2 id="trampas-y-cosas-que-no-son-lo-que-parecen">Trampas y cosas que no son lo que parecen&lt;/h2>
&lt;p>&lt;strong>&amp;ldquo;Langfuse me garantiza trazabilidad total.&amp;rdquo;&lt;/strong> No: el diseño es &lt;strong>best-effort de alto rendimiento&lt;/strong>, no libro contable. Entre el HTTP 207 y la fila en ClickHouse hay buffers volátiles (Redis sin persistencia dura, el buffer en memoria del Worker, el &lt;code>async_insert&lt;/code> server-side de ClickHouse). Hay un &lt;a href="https://github.com/langfuse/langfuse/issues/13468">bug conocido&lt;/a> donde el writer descarta filas sin dead-letter queue tras agotar reintentos. Para observabilidad operativa, perder el 0,01 % de spans es irrelevante. Para &lt;strong>evidencia de auditoría ENS/EU AI Act&lt;/strong> —donde la traza &lt;em>es&lt;/em> la prueba— Langfuse no debe ser el único registro; el log de auditoría regulatorio necesita garantías de durabilidad que esta tubería no promete. Distinción tratada en &lt;a href="https://blog.lo0.es/posts/controles-tecnicos-ens-42001-eu-ai-act/">los controles técnicos ENS/42001/EU AI Act&lt;/a>.&lt;/p>
&lt;p>&lt;strong>Subir el batching de ClickHouse &amp;ldquo;para ir más rápido&amp;rdquo; sin más.&lt;/strong> El knob 3 mejora throughput a costa de agrandar la ventana de pérdida y la latencia de aparición del dato en el dashboard. Lotes de 50.000 cada 10 s rinden de maravilla… hasta que el Worker se reinicia con 50.000 eventos en el buffer. Ajusta con conciencia del trade-off, no maximizando ciegamente.&lt;/p>
&lt;p>&lt;strong>Meter ClickHouse en el mismo nodo que vLLM sin límites.&lt;/strong> ClickHouse es voraz con el ancho de banda de memoria durante los merges. Compartir nodo con vLLM sin &lt;code>resources.limits&lt;/code> ni aislamiento NUMA significa que un pico de ingesta puede degradar el TTFT de la inferencia —exactamente el pecado original que toda esta arquitectura quería evitar. Aísla.&lt;/p>
&lt;p>&lt;strong>Olvidar el filtro temporal en consultas propias.&lt;/strong> Las tablas de ClickHouse están indexadas por &lt;code>projectId&lt;/code> y tiempo. Un dashboard custom o una consulta de la API sin filtro de tiempo escanea todo el histórico y tumba el rendimiento para todos. No es Langfuse que &amp;ldquo;va lento&amp;rdquo;: es una query mal escrita.&lt;/p>
&lt;p>&lt;strong>Aplicar lifecycle al bucket de media.&lt;/strong> Romper los ficheros referenciados en trazas y bloquear futuras subidas (el estado se trackea por hash en Postgres). El bucket de media se gestiona &lt;strong>solo&lt;/strong> con la feature de retención de Langfuse, nunca con reglas ciegas de S3.&lt;/p>
&lt;p>&lt;strong>Tratar el sharding de colas como optimización de rutina.&lt;/strong> Es un knob avanzado para Redis ahogado de verdad, irreversible (no reduzcas shards) y con semántica de concurrencia &lt;em>por shard&lt;/em> fácil de malinterpretar. En la inmensa mayoría de despliegues on-premise no hace falta; si lo activas &amp;ldquo;por si acaso&amp;rdquo;, te complicas la vida sin ganar nada.&lt;/p>
&lt;h2 id="conclusión">Conclusión&lt;/h2>
&lt;p>Langfuse v3 resolvió el problema estructural de la observabilidad LLM —que el observador no asfixie al observado— mudándose de un monolito sobre Postgres a un centro de clasificación de seis servicios con ingesta asíncrona. Ese diseño es lo que permite que un cluster sirviendo miles de tokens por segundo se instrumente entero sin que la app espere jamás a que se archive una traza. Pero el diseño es condición necesaria, no suficiente: rinde si se ajustan las palancas correctas. De los diez knobs, tres deciden casi todo en un despliegue on-premise típico —&lt;strong>escalar Workers por longitud de cola (1), retención + TTL (9), e higiene de system logs (10)&lt;/strong>—; el resto son afinados que aparecen cuando la carga aprieta. Y por encima de todos ellos vive el knob 0, que no es de Langfuse: &lt;strong>el sampling en el Collector&lt;/strong>, que decide cuánto llega a la tubería antes de que ningún ajuste interno importe. Maximizar Langfuse en el cluster 4×H100 no es exprimir su throughput pico: es ponerlo fuera de las GPU, alimentarlo con tráfico ya muestreado, dimensionar el Worker por la cola, y configurar la retención el día uno —para que la herramienta que vino a contar la historia no acabe siendo el capítulo del incidente.&lt;/p>
&lt;h2 id="ver-también">Ver también&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://blog.lo0.es/posts/tracing-llm-otel-genai/">Tracing LLM con OpenTelemetry GenAI&lt;/a> — el pipeline &lt;code>SDK → Collector → backend&lt;/code> que alimenta a Langfuse. Allí se trata Langfuse como destino; aquí se abre por dentro. El sampling de dos capas de aquel post es el knob 0 que multiplica a los diez de este.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/posts/prompt-versioning-langfuse-mlflow/">Prompt versioning con Langfuse y MLflow&lt;/a> — la capa de prompt management que vive en Postgres (no en ClickHouse). El &lt;code>prompt_id@version&lt;/code> que aquel post propaga como span attribute aterriza en las tablas de tracing descritas aquí.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/posts/evals-llm-la-capa-despues-de-tracing/">Evals: la capa después del tracing&lt;/a> — los datasets y evaluators de Langfuse se apoyan en este mismo backend; las trazas almacenadas son el input del eval continuo.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/posts/catalogo-herramientas-oss-llmops/">El catálogo OSS para LLMOps en seis etapas&lt;/a> — la ficha de Langfuse junto a Phoenix y el resto del ecosistema de observabilidad.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/posts/siete-capas-stack-inferencia-llm-on-premise/">El stack de inferencia LLM on-premise en siete capas&lt;/a> — dónde encaja Langfuse (capa 5, observabilidad LLM-aware) en el edificio completo y cómo se dimensiona sobre el mismo cluster 4×H100.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/posts/autoscaling-llm-kubernetes-keda/">Autoescalado de LLMs en Kubernetes con KEDA&lt;/a> — el mecanismo concreto para escalar los Workers de Langfuse por &lt;code>langfuse.queue.ingestion.length&lt;/code> (knob 1), el mismo patrón que para vLLM.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/posts/controles-tecnicos-ens-42001-eu-ai-act/">Controles técnicos: ENS, ISO 42001 y EU AI Act&lt;/a> — por qué Langfuse es observabilidad best-effort y no sustituye al log de auditoría regulatorio con garantías de durabilidad.&lt;/li>
&lt;/ul>
&lt;h2 id="referencias">Referencias&lt;/h2>
&lt;ul>
&lt;li>Langfuse, &lt;em>Scaling Langfuse Deployments&lt;/em> (doc oficial de sizing y todos los env vars de este post): &lt;a href="https://langfuse.com/self-hosting/configuration/scaling">https://langfuse.com/self-hosting/configuration/scaling&lt;/a>.&lt;/li>
&lt;li>Langfuse, &lt;em>Self-host Langfuse&lt;/em> y &lt;em>Configuration via Environment Variables&lt;/em>: &lt;a href="https://langfuse.com/self-hosting">https://langfuse.com/self-hosting&lt;/a> · &lt;a href="https://langfuse.com/self-hosting/configuration">https://langfuse.com/self-hosting/configuration&lt;/a>.&lt;/li>
&lt;li>Langfuse, &lt;em>ClickHouse (self-hosted)&lt;/em>: &lt;a href="https://langfuse.com/self-hosting/deployment/infrastructure/clickhouse">https://langfuse.com/self-hosting/deployment/infrastructure/clickhouse&lt;/a>.&lt;/li>
&lt;li>Langfuse, &lt;em>From Zero to Scale: Langfuse&amp;rsquo;s Infrastructure Evolution&lt;/em> (el porqué del rediseño v2→v3): &lt;a href="https://langfuse.com/blog/2024-12-langfuse-v3-infrastructure-evolution">https://langfuse.com/blog/2024-12-langfuse-v3-infrastructure-evolution&lt;/a>.&lt;/li>
&lt;li>Langfuse, &lt;em>Migrate v2 to v3 (self-hosted)&lt;/em>: &lt;a href="https://langfuse.com/self-hosting/upgrade/upgrade-guides/upgrade-v2-to-v3">https://langfuse.com/self-hosting/upgrade/upgrade-guides/upgrade-v2-to-v3&lt;/a>.&lt;/li>
&lt;li>ClickHouse, &lt;em>Langfuse and ClickHouse: A new data stack for modern LLM applications&lt;/em>: &lt;a href="https://clickhouse.com/blog/langfuse-and-clickhouse-a-new-data-stack-for-modern-llm-applications">https://clickhouse.com/blog/langfuse-and-clickhouse-a-new-data-stack-for-modern-llm-applications&lt;/a>.&lt;/li>
&lt;li>Langfuse, issue #13468 — &lt;em>ClickhouseWriter drops rows after max flush attempts with no DLQ&lt;/em> (la ventana de pérdida documentada): &lt;a href="https://github.com/langfuse/langfuse/issues/13468">https://github.com/langfuse/langfuse/issues/13468&lt;/a>.&lt;/li>
&lt;li>ClickHouse, &lt;em>TTL for tables and columns&lt;/em>: &lt;a href="https://clickhouse.com/docs/guides/developer/ttl">https://clickhouse.com/docs/guides/developer/ttl&lt;/a>.&lt;/li>
&lt;li>OpenTelemetry, &lt;em>Semantic Conventions for Generative AI&lt;/em> (&lt;code>gen_ai.*&lt;/code>): &lt;a href="https://opentelemetry.io/docs/specs/semconv/gen-ai/">https://opentelemetry.io/docs/specs/semconv/gen-ai/&lt;/a>.&lt;/li>
&lt;/ul></description></item></channel></rss>