El KPI que nadie ve
Cuando empezamos a auditar la operación de delivery de un cliente nuevo, hacemos siempre la misma pregunta: "¿cuánto tiempo pasa entre que la cocina marca un pedido como listo y el courier lo recoge?" . La respuesta, en el 90% de los casos, es "no sabemos". A veces escuchamos "menos de 2 minutos, normalmente", que es la forma elegante de decir lo mismo.
A ese intervalo lo llamamos buffer time . Y es el asesino silencioso de la calidad de la comida y de la experiencia del cliente. Mientras el pedido está en el buffer, la pizza pierde temperatura, la salsa pierde textura, las papas pierden todo lo que hace que valga la pena ser papa frita.
Buffer time = timestamp de "courier picked up" − timestamp de "order ready". Se mide por pedido y se agrega por local, hora, día.
Por qué importa tanto
Existe una correlación brutalmente clara entre buffer time y puntaje NPS del cliente. En los datos de Dodo Pizza analizamos ~3.2M pedidos durante 18 meses, y encontramos lo siguiente:
- Buffer < 60s → NPS promedio +72
- Buffer 60–180s → NPS promedio +58
- Buffer 180–300s → NPS promedio +34
- Buffer > 300s → NPS promedio −12 (cliente molesto)
Cada minuto adicional de buffer cuesta puntos NPS reales. Y el cliente nunca escribe en la review "tu courier tardó 4 minutos en recoger mi pedido". Escribe "la pizza llegó fría". La causa raíz queda invisible si no la mides.
Los 4 errores que cometimos
Antes de tener algo decente, fallamos cuatro veces. Vale la pena documentarlos para que no los repitas.
1. Confiar en el timestamp del POS
El primer impulso fue usar
order.completed_at
del
POS como evento "listo". Mala idea. En la mitad de los locales
ese campo se llena cuando el cajero cobra, no cuando la cocina
terminó. La diferencia puede ser 90s. Tu KPI hereda ese sesgo.
Solución: pedir el evento directamente desde el KDS (Kitchen Display System), o instrumentar manualmente el botón "listo" en la cocina con un Raspberry Pi y un código de barras. Sí, hicimos eso en 2 locales para validar.
2. Usar polling cada 30 segundos
La primera versión del pipeline corría un cron cada 30s que consultaba la API del KDS. Para 100 locales eso fueron 12,000 requests por hora — y aún así perdíamos eventos cuando dos órdenes pasaban a "listo" en la misma ventana.
Polling es la forma de no enterarte de cosas que importan. Si tu sistema soporta webhooks o eventos, úsalos. Si no, instrumenta tú mismo.
3. No tener idempotencia desde el día 1
Cuando migramos de polling a webhooks, descubrimos que el KDS
re-envía cada evento 2–3 veces por seguridad. Sin un
event_id
+ dedup, contábamos cada pedido 2x. Métricas
limpias siempre necesitan idempotencia desde el primer commit.
4. Escribir directo a Postgres
La cuarta versión escribía eventos crudos a Postgres y agregaba con queries SQL pesadas. Funcionó hasta los ~30 locales. Después cada query tomaba 8 segundos y el dashboard quedó inusable. Migramos a Delta Lake con Spark Streaming y los queries ahora corren en menos de 200ms.
Arquitectura final
Después de iterar, llegamos a un setup que ya lleva 4 años en producción sin cambios estructurales. Tres capas:
-
Captura
: KDS → webhook HTTPS → Kafka topic
kitchen.events -
Procesamiento
: Spark Streaming consume Kafka, deduplica por
event_id, escribe a Delta Lake (bronze → silver → gold) - Servir : Superset lee la tabla gold, alertas a Telegram via webhook
"Lo más sorprendente no fue el impacto en NPS. Fue que descubrimos 3 locales donde el buffer estaba en 6 minutos consistentemente. Resultó ser el mismo gerente, mala asignación de couriers." — Operations Manager, Dodo Pizza México
Diagrama de flujo
Código que usamos
Esta es una versión simplificada del job de Spark Streaming que
escribe a la tabla
gold.buffer_time_per_order
:
12345678910111213141516171819202122232425from pyspark.sql import functions as F
from delta.tables import DeltaTable
# 1. Read events from Kafka
events = (spark.readStream
.format("kafka")
.option("subscribe", "kitchen.events")
.load()
.select(F.from_json("value", schema).alias("e"))
.select("e.*"))
# 2. Pivot ready / pickup events per order
buffer = (events
.groupBy("order_id", F.window("event_ts", "15 minutes"))
.agg(
F.min(F.when(F.col("type") == "ready", F.col("event_ts"))).alias("ready_at"),
F.min(F.when(F.col("type") == "pickup", F.col("event_ts"))).alias("pickup_at"))
.withColumn("buffer_seconds",
F.unix_timestamp("pickup_at") - F.unix_timestamp("ready_at")))
# 3. Upsert to Delta gold table (idempotent)
def upsert(batch_df, batch_id):
DeltaTable.forName(spark, "gold.buffer_time_per_order") \
.merge(batch_df.alias("new"), "new.order_id = old.order_id") \
.whenMatchedUpdateAll().whenNotMatchedInsertAll().execute()
La parte clave es el
foreachBatch
con
MERGE
:
si el mismo pedido llega 3 veces (por reenvíos del webhook), termina
como una sola fila correcta. Idempotencia por diseño.
Resultados después de 6 meses
Después de instrumentar buffer time en 100+ locales y darle a cada gerente regional acceso al dashboard, los números cambiaron:
- Buffer mediano: de 187s a 84s (−55%)
- % de pedidos con buffer > 5min: de 11.2% a 1.4%
- NPS promedio: +18 puntos en los locales con peor buffer inicial
- Quejas por "comida fría": −63%
Pero el resultado más importante fue cualitativo: los gerentes empezaron a tener conversaciones operacionales que antes no existían. "Tu buffer subió a 4min entre 19:00 y 20:30 los viernes" es accionable. "Tu NPS bajó este mes" no lo es.
Conclusión
Si operás delivery y no estás midiendo buffer time, lo estás perdiendo. Los KPIs que importan rara vez son los que tu dashboard te muestra por defecto — son los que tenés que construir explícitamente porque conoces el dominio.
Si querés que armemos algo similar para tu operación, escribíme a sergephilatov@gmail.com o reservá 30 min acá . La auditoría inicial es gratis.