<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom"><channel><title>Migracion on lo0 — Blog Técnico</title><link>https://blog.lo0.es/tags/migracion/</link><description>Recent content in Migracion on lo0 — Blog Técnico</description><generator>Hugo -- gohugo.io</generator><language>es</language><lastBuildDate>Mon, 14 Sep 2026 08:00:00 +0200</lastBuildDate><atom:link href="https://blog.lo0.es/tags/migracion/index.xml" rel="self" type="application/rss+xml"/><item><title>Langfuse v4, día 2 (4 de 10): migrar de la 3 a la 4 sin ventana, y dónde está el punto de no retorno</title><link>https://blog.lo0.es/posts/langfuse-v4-migrar-sin-ventana/</link><pubDate>Mon, 14 Sep 2026 08:00:00 +0200</pubDate><guid>https://blog.lo0.es/posts/langfuse-v4-migrar-sin-ventana/</guid><description>&lt;blockquote>
&lt;p>Cuarto artículo de una serie de diez sobre operar Langfuse v4 en producción. Los tres anteriores miraban el lado del cliente: &lt;a href="https://blog.lo0.es/posts/langfuse-v4-que-entra-en-una-traza/">qué entra en una traza&lt;/a>, &lt;a href="https://blog.lo0.es/posts/langfuse-v4-instrumentar-langgraph/">lo que cuesta un turno de agente&lt;/a> y &lt;a href="https://blog.lo0.es/posts/langfuse-v4-agentes-reales-odr-gptr/">dos agentes conocidos instrumentados&lt;/a>. Este es el primero del lado del servidor. Verificado contra el código de Langfuse 4.35.0, commit &lt;code>2ee1908&lt;/code> del 14 de septiembre de 2026.&lt;/p>
&lt;/blockquote>
&lt;h2 id="tldr">TL;DR&lt;/h2>
&lt;p>&lt;strong>El valor por defecto del modo de escritura es el destino, no el origen.&lt;/strong> &lt;code>LANGFUSE_MIGRATION_V4_WRITE_MODE&lt;/code> sale de fábrica en &lt;code>events_only&lt;/code> (&lt;code>packages/shared/src/env.ts:337&lt;/code>). Eso está bien para una instalación nueva y está mal para una migración: en ese modo las tablas &lt;code>traces&lt;/code> y &lt;code>observations&lt;/code> dejan de escribirse.&lt;/p>
&lt;p>&lt;strong>El worker se niega a arrancar ante tres combinaciones.&lt;/strong> &lt;code>legacy&lt;/code> con escritura OTel directa, &lt;code>events_only&lt;/code> sin la opción de vista previa, y &lt;code>events_only&lt;/code> con el comportamiento OTel en dual. El comentario del código las llama por su nombre: &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>En &lt;code>events_only&lt;/code> veinticuatro rutas REST devuelven 404.&lt;/strong> Trazas, observaciones, sesiones, scores v1 y v2, métricas, spans, generations y las de dataset runs. No es un fallo, es un cortocircuito deliberado para no servir datos viejos de tablas que ya nadie escribe.&lt;/p>
&lt;p>&lt;strong>El histórico lo rellena una cadena de cinco migraciones de fondo&lt;/strong>, no las migraciones de ClickHouse. Ninguna de las diez migraciones nuevas de ClickHouse copia un solo dato antiguo.&lt;/p>
&lt;p>&lt;strong>Una migración de fondo fallida no bloquea a la siguiente.&lt;/strong> El gestor no tiene modelo de dependencias y las salta. La cadena se protege con guardas de predecesor que &lt;strong>los dos primeros pasos no declaran&lt;/strong>, así que un fallo del paso 1 no impide que arranque el paso 2.&lt;/p>
&lt;p>&lt;strong>Los diez minutos de retraso de la API vieja tienen una explicación exacta.&lt;/strong> Particiones de tres minutos en la tabla de paso, bloqueo cuatro minutos después de crearse, y un cron por minuto que solo toca particiones de más de diez minutos, con concurrencia global de uno.&lt;/p>
&lt;p>&lt;strong>Esa tabla de paso tiene un TTL de cuarenta y ocho horas.&lt;/strong> Si el propagador se para un fin de semana largo, esas particiones desaparecen y el hueco en &lt;code>events_full&lt;/code> es permanente. No hay código que lo detecte ni que lo repare.&lt;/p>
&lt;p>&lt;strong>El punto de no retorno está en Postgres, no en ClickHouse.&lt;/strong> Una migración de Prisma hace &lt;code>DROP TABLE&lt;/code> sobre &lt;code>traces&lt;/code>, &lt;code>observations&lt;/code> y &lt;code>scores&lt;/code>, y Prisma Migrate no tiene reversa.&lt;/p>
&lt;h2 id="estás-aquí-observe-día-2">Estás aquí: OBSERVE, día 2&lt;/h2>
&lt;p>Los tres primeros artículos median lo que produce un agente. Este empieza la parte de operar el backend que lo recibe, y va antes que el de capacidad porque no se puede dimensionar lo que todavía no se ha migrado.&lt;/p>
&lt;h2 id="la-analogía-cambiar-el-ancho-de-vía-sin-cortar-el-servicio">La analogía: cambiar el ancho de vía sin cortar el servicio&lt;/h2>
&lt;p>Cambiar el ancho de vía de una línea en explotación no se hace de golpe. Se tiende el tercer carril, se hace circular material de los dos anchos durante un tiempo, y solo cuando todos los trenes son del ancho nuevo se levanta el carril viejo.&lt;/p>
&lt;p>La migración de Langfuse tiene esa forma exacta. El modo &lt;code>dual&lt;/code> es el tercer carril: se escribe en las tablas viejas y en las nuevas a la vez. Y como en la vía, el peligro no está en el día del cambio, está en los dos extremos. Empezar tendiendo el carril nuevo y levantando el viejo a la vez deja trenes sin poder circular. Y una vez levantado el carril viejo, volver atrás no es cuestión de voluntad, es cuestión de que el material ya no está.&lt;/p>
&lt;h2 id="la-escalera-de-tres-peldaños">La escalera de tres peldaños&lt;/h2>
&lt;p>Todo gira alrededor de una variable con tres valores, definida por triplicado en los tres paquetes que tienen que estar de acuerdo, con el mismo enum y el mismo valor por defecto (&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>Los dos ayudantes que la traducen dicen lo que hace cada peldaño mejor que cualquier explicación (&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>Qué se escribe en cada modo:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Tabla&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>sí&lt;/td>
&lt;td>sí&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>sí&lt;/td>
&lt;td>sí&lt;/td>
&lt;td>sí&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>dataset_run_items_rmt&lt;/code>&lt;/td>
&lt;td>sí&lt;/td>
&lt;td>sí&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>sí&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>sí&lt;/td>
&lt;td>sí&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>events_core&lt;/code>&lt;/td>
&lt;td>no&lt;/td>
&lt;td>sí (por la vista materializada)&lt;/td>
&lt;td>sí&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>El peldaño intermedio es el único donde conviven las dos cosas, y por eso es el único sitio desde el que se puede volver atrás sin perder nada. El propio código lo propone como remedio cuando algo falla en &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>El &lt;em>both&lt;/em> de esa frase importa. Los tres paquetes leen la variable y el comentario del código insiste tres veces en mantenerlos sincronizados. Desplegar el worker con un valor y la web con otro es la primera manera de romper esto.&lt;/p>
&lt;h2 id="las-tres-combinaciones-que-no-arrancan">Las tres combinaciones que no arrancan&lt;/h2>
&lt;p>El worker valida la configuración al arrancar y lanza excepción en tres casos, bajo un comentario que no deja dudas (&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>El segundo es el que muerde en una migración real: &lt;code>events_only&lt;/code> exige además &lt;code>LANGFUSE_MIGRATION_V4_ALLOW_PREVIEW_OPT_IN=true&lt;/code>, porque si no las lecturas de la web apuntarían a las tablas legacy que ese modo ya no escribe. El tercero prohíbe &lt;code>events_only&lt;/code> con el comportamiento OTel en dual.&lt;/p>
&lt;p>Que el arranque falle es la buena noticia. Un fallo de arranque se ve en treinta segundos. Las combinaciones que no valida, en cambio, tardan días en dar la cara.&lt;/p>
&lt;h2 id="qué-se-rompe-en-cada-modo">Qué se rompe en cada modo&lt;/h2>
&lt;p>Esta es la tabla que hay que tener delante antes de mover la variable, porque los dos extremos rompen cosas distintas.&lt;/p>
&lt;p>&lt;strong>En &lt;code>legacy&lt;/code>&lt;/strong>, las tablas de eventos no se escriben, así que lo que depende de ellas desaparece. Las rutas de monitores devuelven 404 por un middleware específico (&lt;code>web/src/server/api/trpc.ts:413&lt;/code>). Las métricas v2 no están disponibles. Los widgets de cuadro de mando se quedan en la versión 1 (&lt;code>DashboardService.ts:39&lt;/code>). La vista previa de la v4 está forzada a apagado y no se puede encender.&lt;/p>
&lt;p>&lt;strong>En &lt;code>events_only&lt;/code>&lt;/strong>, las tablas legacy no se escriben, así que veinticuatro rutas REST se cortocircuitan con 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>La lista cubre &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> y &lt;code>scores/{id}&lt;/code> en v1 y en 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> y las dos de &lt;code>datasets/{name}/runs&lt;/code>. Si tienes integraciones propias contra esas rutas, se caen el día del cambio, y devolver 404 en vez de datos rancios es la decisión correcta aunque duela.&lt;/p>
&lt;p>Un detalle que se suele contar mal: &lt;strong>&lt;code>/api/public/ingestion&lt;/code> no se apaga nunca&lt;/strong>. Lo dice el propio aviso de deprecación (&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>En &lt;code>events_only&lt;/code> esa ruta sigue viva y sigue aceptando dos tipos de evento (&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>El rechazo del resto es &lt;strong>por evento, no por lote&lt;/strong>: el lote devuelve un 207 con errores 400 dentro. Un cliente viejo que mande trazas y scores mezclados verá pasar los scores y fallar el resto, que es justo el tipo de fallo parcial que tarda en detectarse.&lt;/p>
&lt;h2 id="los-tres-pasos-en-orden">Los tres pasos, en orden&lt;/h2>
&lt;p>Con lo anterior, el orden de una migración sin ventana es este:&lt;/p>
&lt;ol>
&lt;li>&lt;strong>Terminar las migraciones de fondo de la v3 antes de subir.&lt;/strong> Esto es previo a todo lo demás y no admite atajos. La v4 borra las filas de siete migraciones antiguas, y el comentario explica por qué (&lt;code>prisma/migrations/20260723124010_drop_dataset_run_items_table/migration.sql:7&lt;/code>): sus scripts se borran con ellas, así que una fila sin terminar dejaría al gestor sin script que resolver. Si alguna quedó a medias, esos datos se pierden y la fila desaparece sin ruido.&lt;/li>
&lt;li>&lt;strong>Subir a v4 con &lt;code>LANGFUSE_MIGRATION_V4_WRITE_MODE=legacy&lt;/code>&lt;/strong>, aplicar las migraciones de ClickHouse y las de Prisma. Aquí la instalación se comporta como la v3 y se comprueba que todo sigue en pie.&lt;/li>
&lt;li>&lt;strong>Pasar a &lt;code>dual&lt;/code>.&lt;/strong> A partir de ahí se escriben las dos familias de tablas y arranca el propagador. Se deja correr el tiempo que haga falta y se lanza el relleno del histórico.&lt;/li>
&lt;li>&lt;strong>Cuando el histórico esté completo y verificado, pasar a &lt;code>events_only&lt;/code>&lt;/strong> con la opción de vista previa activada, y solo entonces levantar el carril viejo.&lt;/li>
&lt;/ol>
&lt;p>Entre el 3 y el 4 está todo el trabajo, y puede durar semanas. No hay prisa: &lt;code>dual&lt;/code> es un estado estable.&lt;/p>
&lt;h2 id="las-migraciones-de-clickhouse-no-rellenan-nada">Las migraciones de ClickHouse no rellenan nada&lt;/h2>
&lt;p>Hay 96 ficheros en &lt;code>packages/shared/clickhouse/migrations/canonical/&lt;/code>, 48 de subida y 48 de bajada, que hay que materializar antes de ejecutar (&lt;code>pnpm ch:migrations:materialize&lt;/code>) y limpiar después, incluso si la migración falló. El ejecutor es golang-migrate.&lt;/p>
&lt;p>Las diez de la v4 son 0039 a 0048. Las tres primeras crean &lt;code>events_full&lt;/code>, &lt;code>events_core&lt;/code> y la vista materializada que alimenta la segunda desde la primera. Las tres últimas de borrado tiran &lt;code>event_log&lt;/code>, &lt;code>project_environments&lt;/code> y &lt;code>dataset_run_items&lt;/code>. Las de en medio añaden columnas e índices.&lt;/p>
&lt;p>Lo importante para planificar es lo que &lt;strong>no&lt;/strong> hacen. Ninguna copia un dato antiguo. La vista materializada solo ve las filas que se inserten en &lt;code>events_full&lt;/code> a partir de su creación. Los índices de las migraciones 0043 y 0047 se añaden sin materializar las partes históricas, y la 0047 lo dice por escrito:&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>Traducido a operación: durante y después de la migración, las búsquedas sobre datos anteriores hacen escaneo completo. No es un fallo, es el precio de que la migración no bloquee.&lt;/p>
&lt;p>Y una trampa de clúster más: las migraciones 0042, 0043, 0047 y 0048 llevan &lt;code>alter_sync = 2&lt;/code>, o sea que cada &lt;code>ALTER&lt;/code> &lt;strong>espera a todas las réplicas&lt;/strong>. Una réplica lenta o caída cuelga la migración entera. En una instalación de una sola réplica esto no se nota; en tres, sí.&lt;/p>
&lt;h2 id="la-cadena-de-cinco-pasos-que-sí-rellena-el-histórico">La cadena de cinco pasos que sí rellena el histórico&lt;/h2>
&lt;p>El relleno lo hacen cinco migraciones de fondo que corren en el worker, registradas en Postgres con nombres que llevan el orden dentro:&lt;/p>
&lt;ol>
&lt;li>
&lt;p>&lt;code>20260701_v4_step_1_create_root_spans_from_traces&lt;/code>: crea spans raíz virtuales en &lt;code>events_full&lt;/code> a partir de las trazas existentes, con el identificador &lt;code>concat('t-', t.id)&lt;/code>. Salta las trazas referenciadas por un dataset run item, que son del paso 4. Trocea por partición mensual.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;code>20260701_v4_step_2_rewrite_observations_to_pid_tid_sorting&lt;/code>: copia &lt;code>observations&lt;/code> a una tabla de trabajo con otro orden de claves. Trocea por partición mensual.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;code>20260701_v4_step_3_backfill_events_full_from_observations&lt;/code>: el grueso. Trocea &lt;strong>por parte de ClickHouse&lt;/strong>, no por partición, y el comentario explica por qué es la única granularidad segura:&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>: recorre los dataset run items por cursor y escribe la raíz y sus descendientes. Lotes de 200, tope de 50.000 descendientes por elemento, ventana de 90 días.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;code>20260701_v4_step_5_drop_pid_tid_sorting_tables&lt;/code>: tira las tablas de trabajo del paso 2.&lt;/p>
&lt;/li>
&lt;/ol>
&lt;p>Solo hay &lt;strong>dos interruptores&lt;/strong> para las cinco. Los cuatro primeros comparten &lt;code>LANGFUSE_BACKGROUND_MIGRATION_V4_ENABLE_HISTORIC_BACKFILL&lt;/code>, que viene en &lt;code>true&lt;/code>, y el quinto tiene el suyo, &lt;code>LANGFUSE_BACKGROUND_MIGRATION_V4_DROP_PID_TID_SORTING_TABLES&lt;/code>, que viene en &lt;code>false&lt;/code> a propósito, para que las tablas de trabajo sigan ahí hasta que el operador confirme que el relleno salió bien.&lt;/p>
&lt;p>&lt;strong>Una sola migración a la vez en todo el clúster.&lt;/strong> El gestor coge un lock en Postgres, lo renueva con un latido cada 15 segundos y considera caducado un lock de más de 60 segundos, todo dentro de una transacción serializable. Añadir réplicas de worker no acelera el relleno ni un minuto. Esto es lo primero que sorprende a quien está acostumbrado a escalar horizontalmente para ir más rápido.&lt;/p>
&lt;p>&lt;strong>Son reanudables, y bastante mejor de lo que suele serlo esto.&lt;/strong> El progreso vive en un campo JSONB de la fila, y al reanudar el worker se &lt;strong>reengancha a las consultas que dejó corriendo el worker anterior&lt;/strong> por su identificador de consulta, en vez de relanzarlas:&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>Sobre cuánto tardan, el código no dice nada. Ni los ficheros, ni el README de la carpeta, ni las migraciones. El único umbral temporal que aparece es de diseño: &lt;em>&amp;ldquo;A good threshold is something that takes more than 5 minutes to run&amp;rdquo;&lt;/em>. Cualquier cifra de horas o días que leas por ahí, incluida la que yo mismo he repetido antes de comprobarlo, no está en el código.&lt;/p>
&lt;h2 id="el-fallo-que-no-bloquea">El fallo que no bloquea&lt;/h2>
&lt;p>Este es el hallazgo que más cambia un runbook. Cuando una migración de fondo falla, el gestor la marca con &lt;code>failedAt&lt;/code> y sigue. Y el predicado con el que elige la siguiente excluye precisamente las que tienen &lt;code>failedAt&lt;/code>, de modo que &lt;strong>una migración fallida se salta y no bloquea a las siguientes&lt;/strong>.&lt;/p>
&lt;p>El propio código reconoce el agujero y explica el parche (&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>La guarda existe, pero &lt;strong>solo en los pasos 3 y 4&lt;/strong>. Los pasos 1 y 2 no declaran predecesor. Un fallo del paso 1 no impide que el paso 2 arranque; lo que salva la situación es que el 3 sí comprueba al 2.&lt;/p>
&lt;p>Consecuencia práctica: la única señal fiable de que el relleno va bien es mirar la tabla &lt;code>background_migrations&lt;/code> en Postgres y comprobar que las cinco filas tienen &lt;code>finishedAt&lt;/code> y ninguna tiene &lt;code>failedAt&lt;/code>. Y para rearrancar una fallida hay que limpiar &lt;code>failedAt&lt;/code> a mano y relanzar con &lt;code>--retry-failed&lt;/code>, como dice el propio mensaje de error.&lt;/p>
&lt;p>Dos modos de fallo concretos que merecen estar en el runbook:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>El paso 3 se rompe si ClickHouse fusiona una parte a mitad de camino.&lt;/strong> El mensaje lo dice todo: &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>. La recuperación es manual, editando el JSONB.&lt;/li>
&lt;li>&lt;strong>El paso 4 se para en seco por un solo trace gigante&lt;/strong>, si supera los 50.000 descendientes. Las tres salidas que ofrece el error son subir el tope, saltar el elemento (y perder ese trace entero) o arreglarlo a mano por SQL.&lt;/li>
&lt;/ul>
&lt;p>Y una asimetría que hay que saber antes de comparar datos: el relleno histórico &lt;strong>no copia los metadatos de la traza a los spans hijos&lt;/strong>, a propósito, mientras que el camino en vivo sí los concatena. El histórico y lo nuevo no son idénticos.&lt;/p>
&lt;h2 id="de-dónde-salen-los-diez-minutos">De dónde salen los diez minutos&lt;/h2>
&lt;p>La API v3 avisa de que sus datos van con unos diez minutos de retraso (&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>Ese retraso es la suma de cuatro cosas, todas visibles en el código:&lt;/p>
&lt;ul>
&lt;li>La tabla de paso particiona por intervalos de &lt;strong>tres minutos&lt;/strong>.&lt;/li>
&lt;li>Una partición se da por cerrada &lt;strong>cuatro minutos&lt;/strong> después de crearse: &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>El cron de propagación corre &lt;strong>cada minuto&lt;/strong> y solo mira particiones más viejas que &lt;code>LANGFUSE_EXPERIMENT_EVENT_PROPAGATION_PARTITION_DELAY_MINUTES&lt;/code>, que vale &lt;strong>10&lt;/strong> por defecto.&lt;/li>
&lt;li>Ese cron tiene &lt;strong>concurrencia global de uno&lt;/strong> en todo el clúster y procesa una sola partición por invocación, así que si hay atasco el retraso crece en vez de recuperarse.&lt;/li>
&lt;/ul>
&lt;p>El modo de quitarse el retraso de encima no es tocar esa variable, es que los clientes escriban por OTLP con escritura directa, que es la ruta en tiempo real. La cabecera que la activa es &lt;code>x-langfuse-ingestion-version: 4&lt;/code>, la mandan solos los SDK de Python desde la 4.0.0 y de JavaScript desde la 5.0.0, y un valor mayor que 4 se rechaza con 400.&lt;/p>
&lt;h2 id="el-ttl-de-cuarenta-y-ocho-horas">El TTL de cuarenta y ocho horas&lt;/h2>
&lt;p>Esta es la que da miedo, y no aparece en ninguna guía de migración.&lt;/p>
&lt;p>La tabla de paso tiene &lt;code>TTL toDateTime(s3_first_seen_timestamp) + INTERVAL 48 HOUR&lt;/code> con &lt;code>ttl_only_drop_parts = 1&lt;/code>. Si el propagador se para más de cuarenta y ocho horas, un puente largo por ejemplo, esas particiones se borran solas. Los datos ya están en las tablas legacy, así que la aplicación en modo &lt;code>dual&lt;/code> sigue funcionando, pero el hueco en &lt;code>events_full&lt;/code> es &lt;strong>permanente&lt;/strong>, y no hay ningún código que lo detecte ni que lo repare. La sonda de salud detecta &lt;em>atascado&lt;/em>, no &lt;em>perdido&lt;/em>.&lt;/p>
&lt;p>La defensa es una alerta, y existe para esto. El endpoint de salud del worker acepta &lt;code>?failIfEventPropagationStuck=true&lt;/code> y devuelve 503 si el latido del propagador lleva sin refrescarse más de &lt;code>LANGFUSE_EVENT_PROPAGATION_STUCK_THRESHOLD_MINUTES&lt;/code>, que vale 35 por defecto. El comentario trae una condición que hay que leer antes de ponerlo en una sonda de Kubernetes:&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>Otro detalle del mismo mecanismo: el cursor de la propagación vive &lt;strong>en Redis&lt;/strong>, no en Postgres. Si Redis se vacía, el cursor vuelve a cero y se reprocesan todas las particiones vivas de la tabla de paso.&lt;/p>
&lt;h2 id="qué-se-puede-deshacer-y-qué-no">Qué se puede deshacer y qué no&lt;/h2>
&lt;p>Las migraciones de bajada de ClickHouse existen, y son honestas sobre lo que hacen. La de la 0039 avisa en mayúsculas:&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>Las de las tablas borradas recrean el esquema y nada más: &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>Pero el punto de no retorno de verdad no está en ClickHouse, está en Postgres. Hay una migración de Prisma llamada &lt;code>20260723150000_drop_legacy_tracing_tables&lt;/code> que hace exactamente lo que dice:&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 no tiene migraciones de bajada. Se aplica y ya está.&lt;/p>
&lt;p>Resumiendo la reversibilidad real: el modo de escritura se puede bajar de &lt;code>events_only&lt;/code> a &lt;code>dual&lt;/code> cuando quieras, y ese es el botón de pánico. Lo que no se recupera es lo que no se escribió mientras estuviste arriba: las tablas legacy no reciben nada retroactivamente. Y el paso 5 de la cadena, el que borra las tablas de trabajo, viene desactivado precisamente para que puedas hacer forense si algo salió mal.&lt;/p>
&lt;p>Una última nota sobre en qué terreno pisas. El comentario de la política de exportaciones deja caer algo para leer dos veces: &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>. La ruta &lt;code>events_only&lt;/code> es la que usarás tú y no la que usa el servicio gestionado del fabricante. Está menos rodada en producción que &lt;code>dual&lt;/code>.&lt;/p>
&lt;h2 id="cómo-queda-esto-en-código">Cómo queda esto en código&lt;/h2>
&lt;p>El runbook mínimo, en 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"># Paso 2: subir a v4 comportándose como 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; aqui no arranca&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"># Paso 3: tercer carril. Los dos servicios, web y worker, a la vez.&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"># Paso 4: solo cuando las cinco filas de background_migrations tengan 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"># sin esto no arranca&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"># el ultimo de todos&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Y la consulta que responde a la única pregunta que importa durante semanas:&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="checklist-de-migración">Checklist de migración&lt;/h2>
&lt;ul>
&lt;li>Las migraciones de fondo de la v3 están todas terminadas antes de subir la imagen.&lt;/li>
&lt;li>Hay copia de seguridad de Postgres y de ClickHouse anterior al primer &lt;code>DROP TABLE&lt;/code>.&lt;/li>
&lt;li>El modo de escritura vale lo mismo en el worker y en la web, y se cambia en los dos a la vez.&lt;/li>
&lt;li>Se ha subido a &lt;code>legacy&lt;/code> primero, y se ha comprobado que el servicio sigue igual.&lt;/li>
&lt;li>Hay una alerta sobre &lt;code>?failIfEventPropagationStuck=true&lt;/code>, con &lt;code>initialDelaySeconds&lt;/code> de al menos 60 segundos.&lt;/li>
&lt;li>Alguien mira la tabla &lt;code>background_migrations&lt;/code> todos los días mientras dure el relleno.&lt;/li>
&lt;li>Se ha inventariado qué integraciones propias llaman a las veinticuatro rutas que van a devolver 404.&lt;/li>
&lt;li>Los SDK de los clientes están en Python 4.0.0 o JavaScript 5.0.0 como mínimo antes de pasar a &lt;code>events_only&lt;/code>.&lt;/li>
&lt;li>El paso 5 de la cadena se activa el último, y solo después de verificar el relleno.&lt;/li>
&lt;/ul>
&lt;h2 id="trampas">Trampas&lt;/h2>
&lt;p>&lt;strong>El valor por defecto es el estado final.&lt;/strong> Una instalación v3 que arranque la v4 sin tocar la variable se pone en &lt;code>events_only&lt;/code> y deja de escribir las tablas viejas desde el primer segundo.&lt;/p>
&lt;p>&lt;strong>Una migración de fondo fallida no bloquea a la siguiente.&lt;/strong> Y los dos primeros pasos de la cadena no declaran predecesor.&lt;/p>
&lt;p>&lt;strong>Más réplicas de worker no aceleran el relleno.&lt;/strong> Una migración a la vez en todo el clúster, por diseño.&lt;/p>
&lt;p>&lt;strong>Cuarenta y ocho horas de propagador parado es un agujero permanente.&lt;/strong> Sin detección y sin reparación.&lt;/p>
&lt;p>&lt;strong>Los índices nuevos no cubren los datos viejos.&lt;/strong> Las búsquedas históricas escanean hasta que alguien materialice los índices a mano.&lt;/p>
&lt;p>&lt;strong>En clúster, cada &lt;code>ALTER&lt;/code> espera a todas las réplicas.&lt;/strong> Una réplica caída cuelga la migración.&lt;/p>
&lt;p>&lt;strong>El rechazo de la ingesta clásica es por evento.&lt;/strong> Un lote mixto devuelve 207 y se pierde la mitad sin que nadie mire el cuerpo de la respuesta.&lt;/p>
&lt;p>&lt;strong>El histórico y el camino en vivo no producen exactamente lo mismo.&lt;/strong> Los metadatos de traza no bajan a los hijos en el relleno.&lt;/p>
&lt;p>&lt;strong>La ruta que vas a usar no es la que usa el fabricante.&lt;/strong> Cloud no corre &lt;code>events_only&lt;/code>.&lt;/p>
&lt;h2 id="la-serie-los-diez-artículos">La serie: los diez artículos&lt;/h2>
&lt;ol>
&lt;li>&lt;a href="https://blog.lo0.es/posts/langfuse-v4-que-entra-en-una-traza/">Qué entra en una traza&lt;/a>: modelo de datos de la versión 4, límites, precedencias, scores, enmascarado e índices.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/posts/langfuse-v4-instrumentar-langgraph/">Poner LangGraph delante&lt;/a>: la instrumentación de una plataforma agéntica y el coste medido de un turno.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/posts/langfuse-v4-agentes-reales-odr-gptr/">Dos agentes conocidos instrumentados&lt;/a>: Open Deep Research y GPT Researcher, con las cifras medidas de una petición real.&lt;/li>
&lt;li>Migrar de la versión 3 a la 4 sin ventana (este artículo): los tres pasos del modo de escritura, las migraciones de fondo reanudables y dónde está el punto de no retorno del retroceso.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/posts/langfuse-v4-colas-del-worker/">Las colas del worker&lt;/a>: el mapa de las treinta y nueve, qué pool dedicar a cada grupo, los interruptores por cola, el particionado y la concurrencia.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/posts/langfuse-v4-capacidad-clickhouse/">Capacidad y coste real de ClickHouse&lt;/a>: cómo medir los bytes por observación con las tablas del sistema, la diferencia entre la tabla completa y la de listados, y el coste de fusión de los índices de texto completo.&lt;/li>
&lt;li>Retención, borrado y protección de datos: por qué un borrado no libera disco, el limpiador de máscaras que viene desactivado, la cola de borrados pendientes y el ciclo de vida de S3 que hay que implementar a mano.&lt;/li>
&lt;li>Copias de seguridad y recuperación cruzada: orden de restauración entre Postgres, ClickHouse y el almacenamiento de objetos, qué rompe cada desajuste, y hasta dónde llega la reproducción de eventos.&lt;/li>
&lt;li>Runbook de saturación: qué alertar de las métricas de cola, las sondas de atasco, el drenado por el endpoint de preparación y la cola de mensajes fallidos.&lt;/li>
&lt;li>Sacar los datos fuera: la integración de almacenamiento de objetos a Parquet, las exportaciones por lotes y la API de métricas, para montar el lago de datos.&lt;/li>
&lt;/ol>
&lt;h2 id="ver-también">Ver también&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://blog.lo0.es/posts/langfuse-v4-que-entra-en-una-traza/">Langfuse v4: qué entra de verdad en una traza&lt;/a>: el modelo de datos al que lleva esta migración.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/posts/langfuse-self-hosting-arquitectura-tuning/">Langfuse por dentro&lt;/a>: la arquitectura de la versión 3, que es de donde se sale.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/posts/litellm-langfuse-par-operativo/">LiteLLM y Langfuse: el par operativo&lt;/a>: qué pasa con el gateway durante el cambio.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/posts/langfuse-v4-agentes-reales-odr-gptr/">Dos agentes conocidos instrumentados&lt;/a>: el volumen que va a tener que tragar la instalación migrada.&lt;/li>
&lt;/ul>
&lt;h2 id="fuentes">Fuentes&lt;/h2>
&lt;ul>
&lt;li>Código de Langfuse 4.35.0, commit &lt;code>2ee1908&lt;/code> del 14 de septiembre de 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">Compatibilidad y migración de Langfuse&lt;/a>, consultado el 14 de septiembre de 2026.&lt;/li>
&lt;li>&lt;a href="https://langfuse.com/integrations/native/opentelemetry">Migración a v4 de la integración OpenTelemetry&lt;/a>, consultado el 14 de septiembre de 2026.&lt;/li>
&lt;/ul></description></item></channel></rss>