Capacidad real de ClickHouse en Langfuse v4: 2.782 bytes por observación, y por qué casi la mitad no son datos

Índice

Sexto artículo de la serie sobre operar Langfuse v4 autoalojado. Los cinco anteriores cubren qué entra en una traza, poner LangGraph delante, dos agentes conocidos instrumentados, migrar sin ventana y las colas del worker. Este mide lo que ocupa todo aquello. Banco ejecutado el 15 de septiembre de 2026 contra el esquema de eventos de la versión 4.36.0, idéntico al de la 4.35.0.

TL;DR

Langfuse no publica ninguna cifra de capacidad. Ni bytes por fila, ni eventos por segundo, ni tamaño esperado. Lo busqué en su código con todas las combinaciones razonables y no hay nada. Lo único cuantificado son las cuotas comerciales de su servicio gestionado, que no dicen nada del disco.

Una observación ocupa 2.782 bytes de disco. Repartidos entre las dos tablas de eventos: 2.347 en la tabla completa y 435 en la de listados. Medido sobre doscientas mil observaciones con la forma que produce un agente real.

El 41,8 % del disco de la tabla completa no son datos, son índices. Cuatro índices de texto completo suman 187 MiB frente a 260 MiB de datos. El que cubre la entrada cuesta 523 bytes por observación, casi tres cuartas partes de lo que cuesta la columna que indexa.

La vista materializada recorta ocho veces el texto y ahorra un dieciocho por ciento de disco. Truncar entrada y salida a doscientos caracteres deja el texto en 64 MiB de los 540 originales, y aun así la segunda tabla añade un 18,5 % sobre la primera. Lo que sobrevive al recorte es el esqueleto de setenta y ocho columnas y una metadata que se trunca elemento a elemento, no en conjunto.

En la tabla de listados, la metadata cuesta más que la entrada y la salida juntas. 132,6 bytes por observación frente a 101,8. Y su índice de texto completo añade otros 111,9, casi tanto como la columna.

La ratio de compresión que se cite sin decir qué se comprimió no vale nada. Mi primera pasada, con un texto repetido, daba 38 veces. Con texto de entropía parecida a la del lenguaje natural da 3,68. Es el mismo esquema, los mismos índices y la misma versión.

Las generaciones son el 11 % de las observaciones y el 65 % de los bytes. Su entrada media son 15.655 bytes y el percentil 95 son 55.205, porque cada una reserializa el historial completo. Es la ley cuadrática del segundo artículo de la serie, aterrizada en disco.

Al borrar, el disco no baja. Un borrado deja de mostrar las filas y deja el espacio ocupado. Hace falta un segundo paso, aplicar la máscara de borrado, y el cron que lo hace viene desactivado de fábrica, igual que el de retención. Además el limpiador trabaja tabla por tabla, así que la segunda tabla conserva las filas que la primera ya no muestra.

Y la retención automática es de pago en autoalojado. El derecho data-retention no está en la edición abierta. Sin él, las tablas de eventos crecen sin límite: no llevan ninguna cláusula de expiración.

Estás aquí: el dimensionado que no se puede hacer leyendo

Los cinco artículos anteriores describen cómo funciona la versión 4 y cómo ponerla a trabajar. Llega el momento de pedir disco, y ahí la serie se queda sin fuentes.

La documentación oficial da un sizing de contenedores: dos CPU y ocho gibibytes para ClickHouse, en Kubernetes ocho de petición y dieciséis de límite, cien gibibytes de disco y tres réplicas mínimas. Es un punto de partida razonable y no responde a la única pregunta que importa, que es cuánto dura ese disco.

Fui a buscarlo al código, que es lo que hace esta serie. Busqué eventos por segundo, filas por observación, bytes por fila, capacidad, benchmark, sizing, en todo el repositorio y en su documentación. No existe ninguna cifra. Lo más cercano son las cuotas de ingesta de su servicio gestionado, que son límites comerciales de API, y unas notas de trabajo de un generador de carga sintética que se refieren a las tablas de la versión anterior y no traen resultados, solo parámetros.

Así que la única salida honesta era medirlo. Este artículo es esa medición, con el método entero para que cualquiera la repita y la contraste con su carga.

La analogía: el archivo y la ficha de índice

Un archivo municipal guarda expedientes. Cada expediente tiene su carpeta con todo dentro, y además el archivero rellena una ficha de índice con los datos de cabecera y las primeras líneas del asunto, para poder buscar sin sacar la carpeta.

La intuición dice que las fichas no ocupan nada comparadas con las carpetas. Y es verdad si uno mira solo el papel. Deja de serlo cuando el archivero, para poder buscar por cualquier palabra, monta además un fichero de términos: una caja con una tarjeta por cada palabra distinta que aparece en cualquier expediente, apuntando a dónde sale. Esa caja crece con el vocabulario, no con el número de expedientes, y acaba ocupando casi tanto sitio como los expedientes.

En este montaje las carpetas son la tabla completa, las fichas son la tabla de listados, y el fichero de términos son los índices de texto completo. El artículo mide las tres cosas por separado, porque quien dimensiona mirando solo las carpetas se queda corto por casi la mitad.

El banco

Quiero que esto sea reproducible, así que el método va entero.

El esquema es el real. Extraje el DDL de las migraciones de ClickHouse del repositorio, en su variante de instancia única. No son tres migraciones sino seis: las tres que crean las dos tablas y la vista materializada, más una que añade tres columnas de atribución de ingesta, otra que añade un índice de n-gramas sobre la metadata y otra que añade tres columnas de evaluación y dos índices. El resultado son 78 columnas por tabla, once índices de salto en la tabla completa y catorce en la de listados.

Verifiqué que ese DDL es idéntico entre la versión que tengo anclada en la serie y la cabecera actual del repositorio: el diferencial del directorio de migraciones está vacío.

El motor es ClickHouse 26.9.1. El mínimo que exige la versión 4 es 25.12, por los índices de texto que necesita el esquema. Uso una versión más nueva, y eso es una diferencia con una instalación que respete el mínimo.

Los datos son sintéticos y con la forma correcta. Las filas no se las inventa el banco: las columnas y sus valores típicos salen de la función del worker que construye el registro. Escribo únicamente en la tabla completa, que es lo que hace el worker, y dejo que la vista materializada pueble la segunda. Así se reproduce la amplificación de escritura real.

La topología de cada traza reproduce la ley que medimos en el segundo artículo de la serie: cuatro observaciones fijas más cinco por cada llamada a herramienta. El número de llamadas por turno sale de una distribución con mediana baja y cola larga, con tope en quince. Las generaciones llevan el historial de mensajes completo, que crece con el turno, porque es exactamente lo que hace el instrumentador y es el origen del crecimiento cuadrático de bytes.

Resultado: 200.012 observaciones en 22.563 trazas, es decir 8,86 observaciones por traza.

Y la entropía del texto está calibrada, que es la parte que casi arruina el banco. Mi primera pasada usaba un párrafo repetido como relleno. Con ese texto, la tabla completa comprimía 38,1 veces y las cifras salían preciosas y falsas. Un lorem ipsum repetido es la mejor entrada posible para un compresor. Rehice el generador con un vocabulario amplio muestreado con una distribución de Zipf y un quince por ciento de identificadores de alta entropía, calibrado para dar la ratio que da el lenguaje natural mezclado con identificadores.

Con ese texto, la misma tabla comprime 3,68 veces. Diez veces menos. Cualquier cifra de capacidad de ClickHouse que no diga qué se comprimió es ruido, y la mía también lo sería sin este párrafo.

Lo que ocupa una observación

Las dos tablas, tras forzar la fusión final:

events_fullevents_core
Filas200.012200.012
Sin comprimir957,87 MiB328,73 MiB
Comprimido260,29 MiB60,34 MiB
Ratio3,685,45
En disco447,68 MiB82,92 MiB
Crudo por observación5.022 B1.723 B
Comprimido por observación1.364,6 B316,3 B
En disco por observación2.347 B435 B

La cifra que hay que llevarse es la última fila sumada: 2.782 bytes de disco por observación, contando las dos tablas, con una réplica y sin retención.

Y hay que fijarse en la distancia entre las dos últimas filas. El dato comprimido de la tabla completa son 1.364,6 bytes por observación, pero en disco ocupa 2.347. La diferencia, 982 bytes por observación, es el apartado siguiente.

Dónde se van los bytes

Desglosado por columna, con lo que cuesta cada una por observación:

Columnaevents_fullevents_core
input717,5 B61,5 B
metadata_values389,0 B132,6 B
output174,7 B40,3 B
tool_definitions28,0 B28,1 B
blob_storage_file_path12,6 B12,5 B
span_id8,1 B8,0 B
Las otras 67 columnas juntas17,3 B13,3 B

Tres cosas saltan.

La primera es que el esqueleto es barato. Sesenta y siete columnas, la mayoría vacías en una observación normal, cuestan diecisiete bytes entre todas. ClickHouse comprime una columna de cadenas vacías casi a nada. Quien temiera que las trece columnas de experimento o las de telemetría pesaran, puede dejar de temerlo.

La segunda es que en la tabla completa la entrada es la mitad de todo. 717,5 de 1.364,6 bytes comprimidos. Es la consecuencia directa de que cada generación reserialice el historial.

La tercera es la que no esperaba. En la tabla de listados, la metadata cuesta más que la entrada y la salida juntas: 132,6 frente a 101,8. El motivo está en cómo trunca la vista materializada. Recorta la entrada y la salida a doscientos caracteres cada una, pero la metadata la recorta elemento a elemento: un vector con cuarenta valores puede sobrevivir con ocho mil caracteres. La tabla que existe para ser ligera se llena de la única cosa que su truncado no controla.

El reparto por tipo de observación

TipoObservacionesEntrada mediaEntrada p95Salida media
GENERATION21.95215.655 B55.205 B1.077 B
AGENT22.5632.585 B6.013 B1.300 B
TOOL21.952319 B319 B2.008 B
CHAIN67.689413 B413 B20 B
SPAN65.856272 B272 B206 B

Las generaciones son el 11 % de las observaciones y aportan el 65 % de los bytes de entrada y salida. El percentil 95 de su entrada son 55 kilobytes, en un banco donde ninguna conversación pasa de quince llamadas a herramienta.

Esto tiene una consecuencia de dimensionado que conviene decir en voz alta: el tamaño de la base no lo determina el número de peticiones, lo determina la profundidad de los turnos. Duplicar los usuarios duplica el disco. Duplicar las llamadas a herramienta por turno lo multiplica por bastante más que dos.

Los índices, que son casi la mitad

Aquí está el hallazgo que más cambia un dimensionado.

DatosÍndices% del disco en índices
events_full260,29 MiB187,31 MiB41,8 %
events_core60,34 MiB22,49 MiB27,1 %

Las marcas y la clave primaria son irrelevantes: treinta y dos kilobytes y trescientos bytes respectivamente, con el tamaño de gránulo de 64 mebibytes que la tabla completa lleva declarado.

Desglosados, y ordenados por lo que cuestan:

ÍndiceTablaBytes por observación
idx_fts_input_lowevents_full523,1
idx_fts_metadata_valuesevents_full319,4
idx_fts_output_lowevents_full133,5
idx_fts_metadata_valuesevents_core111,9
idx_fts_metadata_namesambas4,3
idx_span_idambas1,2
El resto de filtros de Bloomambas0,1 cada uno

El índice de texto completo sobre la entrada cuesta 523 bytes por observación cuando la columna que indexa cuesta 717. Es decir, indexar la entrada cuesta casi tres cuartas partes de lo que cuesta guardarla. Y el de la metadata en la tabla de listados cuesta 111,9 cuando su columna cuesta 132,6: un ochenta y cinco por ciento.

Los filtros de Bloom, en cambio, son gratis a efectos de dimensionado. Los cuatro de identificadores y los de nombre de modelo, experimento y evaluador suman menos de dos bytes por observación entre todos.

Esto no es una crítica al diseño. Esos índices son los que hacen que la búsqueda por texto en la interfaz responda, y sin ellos la experiencia sería otra. Es información de dimensionado que no está publicada en ningún sitio: al pedir disco para ClickHouse hay que multiplicar por 1,7 lo que digan las columnas.

La vista materializada como amplificador

La segunda tabla existe para que los listados no tengan que tocar los campos grandes. Trunca la entrada y la salida a doscientos caracteres y se queda sin los índices de texto sobre ellos.

El recorte funciona: los 540,55 MiB de entrada y salida del banco se quedan en 64,68 MiB, ocho veces y media menos.

Y aun así la segunda tabla añade un 18,5 % de disco sobre la primera. Por lo que ya hemos visto: sobrevive el esqueleto, sobrevive la metadata truncada elemento a elemento, y su propio índice de texto sobre esa metadata cuesta otros 111,9 bytes.

Conviene tenerlo presente porque es contraintuitivo. Uno pensaría que una tabla que guarda doscientos caracteres de texto es un apéndice. Son cuatrocientos treinta y cinco bytes por observación, casi una quinta parte del total, y se pagan siempre, porque la vista materializada no se puede desactivar sin dejar la interfaz sin listados.

Qué pasa al borrar

El cron de retención ejecuta un borrado ligero. Lo reproduje: borré las observaciones anteriores a una fecha, lo que quitó 67.395 filas de las 200.012.

MomentoFilas visiblesFilas en las partesDisco
Antes200.012200.012447,68 MiB
Tras el borrado132.617200.012447,68 MiB
Tras aplicar la máscara132.617132.617299,11 MiB

El borrado no libera ni un byte. Las filas dejan de verse y el espacio sigue ocupado. El disco baja solo en el segundo paso, aplicar la máscara de borrado, que libera el 33,2 %.

Y ese segundo paso lo ejecuta un cron distinto que viene desactivado de fábrica, igual que el de retención. Con los valores por defecto, quien active la retención en la interfaz verá desaparecer las trazas de la vista y no verá bajar el disco jamás, hasta que una fusión ordinaria arrastre las partes afectadas, que puede tardar lo que tarde.

Hay un tercer detalle. El limpiador trabaja tabla por tabla, con un borrador independiente para cada una. En mi prueba borré en la tabla completa y la de listados conservó sus 200.012 filas, porque la vista materializada no propaga borrados. En una instalación real los dos borradores corren, pero son dos operaciones separadas que pueden divergir si una falla.

Sobre esto hay que añadir lo que ya cubrió el cuarto artículo: ninguna de las dos tablas lleva cláusula de expiración. No hay caducidad automática en el motor. Todo el ciclo de vida depende de esos crones. Y la retención por proyecto exige un derecho de la edición empresarial, así que en la edición abierta la única retención posible es la que uno escriba.

Proyección

Con los 2.782 bytes por observación medidos, una réplica y sin retención:

Observaciones al díaTurnos de agente al díaPor díaPor mesPor año
100.000~11.3000,26 GiB7,8 GiB94,6 GiB
1.000.000~112.8002,59 GiB77,7 GiB945,6 GiB
10.000.000~1.128.00025,91 GiB777,2 GiB9.456 GiB

La conversión a turnos usa las 8,86 observaciones por traza del banco, que dependen de la profundidad de los agentes. Con agentes más profundos, la misma cifra de turnos produce más observaciones.

Y falta multiplicar. Langfuse recomienda tres réplicas mínimas de ClickHouse en Kubernetes. Con un millón de observaciones diarias y tres réplicas, un año son 2,8 tebibytes de disco bruto. El sizing oficial de cien gibibytes por réplica cubre unos treinta y ocho días a ese ritmo.

Ese es el número que no está publicado en ningún sitio y que hay que llevar a la hoja de cálculo antes de comprometer un servicio.

Lo que este banco no mide

Por honestidad, y porque cualquiera que repita esto va a obtener números distintos:

Los datos son sintéticos. La forma de la fila y la topología de las trazas están tomadas del código y de las mediciones de los artículos anteriores, pero el texto lo genera un muestreador, no un modelo. He calibrado su entropía para que la compresión se parezca a la del lenguaje natural, y esa calibración es el mayor origen de error de todo el artículo. Una carga con mucho JSON estructurado comprimirá mejor; una con mucho código o mucha base64, peor.

Es una sola instancia, sin replicación. En una instalación replicada hay un motor distinto y hay copias.

Es ClickHouse 26.9.1, por encima del mínimo de 25.12. Las implementaciones de los índices de texto han cambiado entre versiones y no he comparado.

No mide rendimiento: ni ingesta por segundo, ni latencia de consulta, ni coste de CPU de las fusiones. Solo disco. El coste de fusión de los índices de texto completo es un tema con entidad propia y se queda para el runbook de saturación.

Y no mide el almacenamiento de objetos, que en la versión 4 es obligatorio y guarda una copia del evento original. Eso es disco adicional que este banco no toca.

Checklist

  • No dimensionar con el sizing oficial de contenedores: cubre CPU y memoria, no el crecimiento del disco.
  • Contar 2.782 bytes por observación como punto de partida, y ajustar midiendo la carga propia.
  • Multiplicar por el número de réplicas antes de pedir el volumen.
  • Multiplicar por 1,7 lo que sugieran las columnas, porque los índices son el 41,8 % del disco de la tabla completa.
  • Medir la propia instalación con las tablas del sistema en lugar de fiarse de este artículo: partes activas para el total, columnas para el desglose, índices de salto para lo que no son datos.
  • Vigilar la metadata, que es lo único que el truncado de la vista materializada no controla, porque recorta cada elemento y no el conjunto.
  • Dimensionar por profundidad de turno, no por número de peticiones.
  • Activar los dos crones, el de retención y el de la máscara de borrado, y comprobar que el disco baja de verdad después del segundo.
  • Contar con que la retención por proyecto exige licencia, y presupuestar el trabajo de escribirla si no se tiene.
  • Recordar que las tablas no tienen caducidad en el motor: sin cron, crecen para siempre.

Trampas y cosas que no son lo que parecen

  • Langfuse no publica ninguna cifra de capacidad en su código ni en su documentación.
  • Una ratio de compresión sin decir qué se comprimió no significa nada: el mismo esquema da 38 veces con texto repetido y 3,68 con texto realista.
  • El 41,8 % del disco de la tabla completa son índices, no datos.
  • Indexar la entrada cuesta casi tanto como guardarla: 523 bytes por observación frente a 717.
  • El esqueleto de 78 columnas es irrelevante: diecisiete bytes por observación entre las sesenta y siete columnas pequeñas.
  • En la tabla de listados la metadata cuesta más que la entrada y la salida juntas.
  • El truncado de la vista materializada recorta cada elemento de la metadata, no el vector entero.
  • La tabla de listados no es un apéndice: añade el 18,5 % del disco de la primera pese a guardar doscientos caracteres.
  • Un borrado no libera disco. Hace falta aplicar la máscara, y ese cron viene desactivado.
  • El de retención también viene desactivado de fábrica.
  • El limpiador trabaja tabla por tabla; la vista materializada no propaga borrados.
  • Las tablas de eventos no llevan cláusula de expiración.
  • La retención por proyecto es de pago en autoalojado.
  • Las generaciones son el 11 % de las observaciones y el 65 % de los bytes.
  • Los filtros de Bloom son gratis a efectos de disco; los de texto completo no.

Cierre

Lo que me llevo de este banco no es la cifra, aunque la cifra hacía falta. Es que el reparto no está donde uno lo busca.

Cuando alguien dimensiona una base de trazas, mira el tamaño de los prompts. Es lo natural: son los campos grandes y son los que se ven en la interfaz. Y resulta que en esta arquitectura los prompts son la mitad del dato comprimido, y el dato comprimido es poco más de la mitad del disco. El resto son estructuras de búsqueda que nadie contabiliza porque no aparecen al mirar una fila.

Hay un patrón ahí que va más allá de esta herramienta. Una base de datos de observabilidad es, por definición, una base que se consulta de formas imprevistas: por texto libre, por usuario, por sesión, por lo que haga falta cuando algo va mal a las tres de la mañana. Esa capacidad de buscar por cualquier cosa se paga en disco, siempre, y se paga en el momento de escribir y no en el de buscar. El diseño de Langfuse la paga de forma bastante explícita, con cuatro índices de texto completo declarados en la migración, a la vista de quien quiera leerlos.

Lo que falta es que alguien ponga el número al lado. Ahora está puesto, con su margen de error y su método encima de la mesa, para que el siguiente que lo mida pueda decirme en qué me he equivocado.

La serie: los diez artículos

  1. Qué entra en una traza: modelo de datos de la versión 4, límites, precedencias, scores, enmascarado e índices.
  2. Poner LangGraph delante: la instrumentación de una plataforma agéntica y el coste medido de un turno.
  3. Dos agentes conocidos instrumentados: Open Deep Research y GPT Researcher, con las cifras medidas de una petición real.
  4. Migrar de la versión 3 a la 4 sin ventana: los tres pasos del modo de escritura, las migraciones de fondo reanudables y dónde está el punto de no retorno del retroceso.
  5. Las colas del worker: el mapa de las treinta y nueve, qué pool dedicar a cada grupo, los interruptores por cola, el particionado y la concurrencia.
  6. Capacidad y coste real de ClickHouse (este artículo): 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.
  7. 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.
  8. 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.
  9. 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.
  10. 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.

Ver también

Fuentes