Dejé de escribir el mismo producer de Kafka: ahora es un contenedor de 17 MB
El ritual era siempre el mismo. Tocaba un consumer, necesitaba mensajes en un topic para ver si funcionaba, y ahí empezaba la parte tonta: abrir el proyecto, escribir un test que en realidad no era un test, hardcodear un JSON, correrlo, borrarlo. O pelear con
kafka-console-producer para mandar algo con headers y una key específica. O buscar aquel script de hace tres meses que ya no estaba en ninguna parte.Nada de eso es difícil. Ese es justamente el problema: es trabajo que no cuesta pensarlo y aun así se cobra veinte minutos cada vez. Un día me cansé y lo convertí en un servicio.
Se llama
dummy_kafka_producer y hace algo muy corto de explicar: recibe un POST y publica el body en el topic que le digas. Con el tiempo se le sumó un motor de carga, y hoy también sirve para estresar un broker. No hay nada que clonar ni que compilar. Es una imagen de 17 MB.bash
docker run --rm -p 8881:8881 \
-e KAFKA_BROKERS=host.docker.internal:9092 \
ghcr.io/jesaavedrav/dummy_kafka_producer:latestY si tampoco tienes un broker a mano,
docker compose up levanta un Kafka de un solo nodo con KRaft, sin ZooKeeper, y el producer ya apuntado a él. De cero a un mensaje publicado en un comando.Un POST y ya
La interacción básica es esta, y es todo lo que necesitas el 90% de las veces:
bash
curl -X POST http://localhost:8881/kafka-client/api/event \
-H 'topic: orders' \
-H 'key: customer-42' \
-H 'Content-Type: application/json' \
-d '{"order_id":1001,"total":25.5}'json
{"topic":"orders","count":1,"elapsed_ms":12,
"records":[{"topic":"orders","partition":3,"offset":18402}]}Fíjate en lo que devuelve: la partición y el offset que el broker asignó. Eso no es decoración. Es la diferencia entre creer que publicaste y saber dónde quedó el dato.
Para lograrlo el endpoint publica un mensaje a la vez y espera el acknowledgement de cada uno. Es más lento que disparar y olvidar, y es a propósito: si el broker rechaza la escritura, la respuesta es un
502 y no un 200 mentiroso. Un error de Kafka nunca se disfraza de éxito.Los headers también están resueltos. Todo lo que mandes con el prefijo
X-Kafka-Header- viaja como header de Kafka, y el resto se queda como detalle HTTP:bash
curl -X POST http://localhost:8881/kafka-client/api/event \
-H 'topic: orders' \
-H 'X-Kafka-Header-Trace-Id: abc-123' \
-H 'X-Kafka-Header-Source: manual-test' \
-H 'Content-Type: application/json' -d '{"hello":"world"}'Ese mensaje llega con los headers
trace-id y source. Si tu consumer rutea por headers, ya puedes probar esa rama sin tocar código.Cuando un mensaje no alcanza
El segundo endpoint apareció cuando la pregunta dejó de ser si el consumer funciona y pasó a ser cuánto aguanta. Publica a un rate objetivo durante un tiempo, y devuelve de inmediato: no te deja una conexión abierta cinco minutos.
bash
curl -X POST http://localhost:8881/kafka-client/api/loadtest \
-H 'Content-Type: application/json' \
-d '{"topic":"orders","rate":20000,"duration":"60s",
"key":"customer-{{randint:1:500}}","acks":"one"}'El
key de ahí arriba importa más de lo que parece. Una prueba de carga con la misma key en todos los mensajes manda todo a una sola partición y mide algo que no se parece a tu producción. Por eso los placeholders se resuelven por mensaje: {{uuid}}, {{seq}}, {{now_ms}}, {{randint:1:500}} y {{randstr:n}} para inflar el payload al tamaño que quieras probar.Después consultas la corrida y el mismo documento te sirve de progreso mientras vive y de resultado cuando termina:
json
{
"status": "completed", "sent": 300000, "failed": 0, "skipped": 0,
"achieved_rate": 19632, "bytes": 73800000, "elapsed_ms": 15154,
"latency": {"p50_ms": 7.81, "p95_ms": 14.2, "p99_ms": 16.75,
"max_ms": 98.3, "mean_ms": 8.4}
}La decisión de la que estoy más orgulloso
Ese JSON tiene un campo que se ve aburrido y es el más importante de todos:
skipped.Le pides 50.000 mensajes por segundo a un generador. El generador no da abasto, manda 5.000, y te reporta que envió 5.000. Técnicamente no mintió. Pero tú vas a leer ese número creyendo que tu consumer sobrevivió a 50.000, y no sobrevivió a nada: nunca los vio. Vas a sacar una conclusión falsa de un dato verdadero.
Así que la herramienta cuenta explícitamente los mensajes que nunca logró soltar, y cuando el número no es cero lo dice con palabras, no escondido en un campo:
json
{"sent": 500503, "achieved_rate": 48405, "skipped": 495497,
"note": "could not keep up: 495497 messages were never released, raise workers or lower rate"}Quedarse corto siempre se reporta
Un generador de carga que publica menos de lo que dice es peor que no tener generador. Sin él sabes que no mediste; con él crees que mediste. La regla que seguí en todo el proyecto es que la herramienta prefiere verse mal antes que dejarte sacar una conclusión equivocada.
Lo que cuesta sostener eso por dentro
Para poder contar lo que no se envió hay que separar dos cosas que normalmente van juntas: quién decide el ritmo y quién hace el trabajo.
Pacer, cola de tokens y pool de workers
El pacer despierta cada 10 milisegundos y libera la porción de mensajes que toca en esa rebanada, arrastrando el residuo fraccionario a la siguiente para que los rates bajos también salgan exactos. No hay un timer por mensaje: eso deja de funcionar pasados unos pocos miles por segundo.
Los workers consumen de esa cola, publican y esperan su ack. Cuando el reloj se acaba, el trabajo se detiene y lo que quedó en la cola se cuenta como
skipped. Una corrida que pediste de 30 segundos dura 30 segundos: el backlog no se publica tarde para maquillar el total, porque publicarlo tarde sería inventar un rate que nunca existió.Hay un detalle fino ahí. Los workers publican con el contexto de la corrida y no con el del reloj, así que un mensaje que ya iba en vuelo cuando sonó la campana alcanza a terminar en vez de contarse como un fallo del broker. Sin esa distinción, cada prueba terminaría con una ráfaga de errores falsos justo en la línea de meta.
El histograma y un p50 imposible
Las latencias van a un histograma de 160 buckets que crecen 10% cada uno, cubriendo desde 50 microsegundos hasta un par de minutos. Con eso, un percentil nunca se aleja más de un 10% del valor real y las escrituras quedan libres de locks, que es lo que permite consultar una corrida en vivo sin frenarla.
Y aquí una de esas cosas que solo aparecen cuando usas tu propia herramienta: un percentil que se redondea al borde superior de su bucket puede terminar siendo mayor que el máximo observado. Es aritméticamente correcto y se lee como un bug. Un
p50 de 12 ms con un max de 9 ms hace que dejes de confiar en el reporte completo, y con razón. Por eso los percentiles se recortan al valor más grande que realmente se midió. La precisión nominal importa menos que no publicar un número que se contradice a sí mismo.Los detalles que hacen que funcione a la primera
La mayor parte del trabajo no está en el camino feliz. Está en las cosas que, de no estar, te hacen perder la mañana:
Las keys particionan igual que tu app en la JVM. El servicio usa el particionador Murmur2, el mismo que trae por defecto el cliente de Java. Una key que en producción cae en la partición 3 cae aquí en la partición 3. Si no fuera así, cualquier prueba de orden por key mediría un escenario que no existe.
Un topic nuevo funciona al primer intento. El cliente de Go crea el topic faltante mientras responde la petición de metadata detrás de la primera escritura fallida, pero no reintenta, así que la primera publicación a un topic nuevo fallaría. Ambos caminos reintentan exactamente ese error, y una prueba de carga espera a que el topic exista antes de arrancar, para que una ráfaga inicial de fallos no ensucie el resultado.
El apagado es ordenado. Con
SIGTERM el servidor deja de aceptar peticiones, las publicaciones en vuelo terminan, las pruebas activas se cancelan y lo que quedó en el buffer se envía antes de salir.Todo límite es política, no física. Rate máximo, duración máxima, workers máximos, pruebas concurrentes: cada uno existe para que una petición descuidada no funda el contenedor, y cada uno se sube con una variable de entorno. Nada te obliga a pedir permiso.
La imagen es distroless y corre como nonroot. El binario compila estático y viaja solo, sin shell y sin gestor de paquetes. El CI corre
gofmt, go vet y go test -race, y publica para amd64 y arm64 en cada push a main. Los tests no necesitan un broker: la capa de Kafka está detrás de una interfaz y se reemplaza por una falsa.Nada de eso es heroico. Todo eso es lo que separa una herramienta que usas de un script que abandonas.
Y ahora la parte que no esperaba: dáselo a tu agente
Esto empezó a pasar solo. Ahora que trabajo casi siempre con un agente de código al lado, noté que estaba repitiendo una variante del ritual viejo: pedirle al modelo que me generara un producer, o un script de Python con
kafka-python, o un JSON de ejemplo para meterlo a mano en el topic. Cada vez desde cero. Cada vez un poco distinto.Es un mal negocio y vale la pena decir por qué. Le estás pidiendo a un sistema probabilístico que resuelva un problema que ya tenía solución determinística. El código generado hay que leerlo, las dependencias hay que instalarlas, la versión de la librería puede no ser la que el modelo recuerda, y cuando falla no sabes si falló Kafka, el script o el prompt. Pagas tokens y atención por reconstruir cada vez algo que podría ser una llamada estable.
Una herramienta estable le gana a un prompt repetido
Si algo lo vas a necesitar más de tres veces, conviene que exista como herramienta y no como instrucción. La herramienta tiene un contrato, falla siempre igual y su error te dice qué pasó. El prompt tiene que acertar de nuevo cada vez que lo corres.
La buena noticia es que un endpoint HTTP ya es, prácticamente, una herramienta de agente. Solo hay que darle la facultad de usarla, y eso son dos pasos.
Paso 1: escríbelo en el contexto del proyecto
Un bloque en tu
CLAUDE.md o AGENTS.md convierte esto en algo que el agente sabe hacer sin que tú se lo expliques otra vez:CLAUDE.md
## Publishing test messages to Kafka
Do not write throwaway producer scripts. A producer is already running
on port 8881 (`docker compose up -d` if it is not).
Publish one message:
curl -X POST http://localhost:8881/kafka-client/api/event \
-H 'topic: <topic>' -H 'key: <key>' \
-H 'Content-Type: application/json' -d '<json body>'
The response contains the partition and offset. A 502 means the broker
rejected the write; a 400 means the request was malformed.
Put the consumer under load (returns immediately, then poll the run):
curl -X POST http://localhost:8881/kafka-client/api/loadtest \
-H 'Content-Type: application/json' \
-d '{"topic":"<topic>","rate":5000,"duration":"30s","key":"{{uuid}}"}'
Always check `skipped` in the result. If it is above zero the target
rate was never reached and the numbers do not mean what they look like.Ese último párrafo es el que más rinde. No solo le das el comando: le dices cómo leer el resultado. Un agente que reporta "la prueba pasó, 5.000 mensajes por segundo" sin mirar
skipped te está entregando la misma conclusión falsa que te entregarías tú.Paso 2: quítale el peaje
Si tu agente pide permiso en cada
curl, terminas aprobando lo mismo veinte veces y la ganancia se evapora. En Claude Code se resuelve con una regla en settings.json, acotada a ese host y ese puerto:.claude/settings.json
{
"permissions": {
"allow": [
"Bash(curl -X POST http://localhost:8881/kafka-client/api/event:*)",
"Bash(curl -X POST http://localhost:8881/kafka-client/api/loadtest:*)",
"Bash(curl -s http://localhost:8881/kafka-client/api/loadtest:*)"
]
}
}Con eso el agente puede publicar contra tu broker local, revisar el offset donde cayó el mensaje, correr el consumer y comparar. El ciclo completo de probar un consumer deja de pasar por ti.
Y si quieres ir un paso más allá, un servidor MCP delgado por encima de estos dos endpoints le da al agente una herramienta con schema en vez de un comando de shell. No es necesario para empezar: el
curl documentado cubre casi todo el valor, y funciona con cualquier agente que sepa usar una terminal.Lo que me llevo
El proyecto empezó siendo un servicio en la JVM, de esos que uno hace en una tarde. Después lo reescribí en Go, básicamente para que la imagen dejara de pesar cientos de megas y pudiera correrla sin pensarlo. Ahí empezó a ser otra cosa.
Lo primero que aprendí es que el salto de utilidad no está en las funcionalidades, está en la honestidad del reporte. Cualquiera publica mensajes en un topic. Lo difícil, y lo que hace que la herramienta valga, es que te diga cuándo no pudo.
Lo segundo es que escribir el README como si el repo fuera de otra persona cambió el diseño. Cada vez que una sección me quedaba enredada de explicar, el problema no era la explicación: era la interfaz. La documentación terminó siendo el mejor code review que tuvo el proyecto.
Y lo tercero, que no vi venir: la mejor forma de aprovechar la IA en este caso no fue pedirle que escribiera el producer, sino construir uno bueno una sola vez y darle la llave. La herramienta hace lo determinístico, el modelo hace lo que requiere criterio. Cada uno en lo suyo.
Está en github.com/jesaavedrav/dummy_kafka_producer. Un
docker run y ya lo tienes.