Alarmas que despiertan por causa real, no por un número random
El escenario
El escenario es un trabajo en segundo plano en una plataforma de comunidad: un matcher nocturno que empareja items nuevos contra un pool de usuarios elegibles y escribe filas a una tabla matches. Pero la lección es general: cualquier alarma sobre un conteo de salida (items emparejados, correos enviados, registros procesados, recomendaciones generadas) tiene el mismo fallo.
- elchesco / blog-cause-aware-alarms - Code companion - alarms that page on cause, not on a number
- 00-output-threshold-alarm/ - the naive version: emitter + alarm that page on a COUNT
- 01-diagnose/ - read-only SQL that falsifies the alarm's hypothesis
- 02-instrument-the-emitter/ - the pure classifier + the instrumented job
- 03-cause-aware-alarm/ - same threshold, cause-accurate line + a benign metric
- 04-test-the-gate/ - test the classification, not the plumbing
Reading order:
00-output-threshold-alarm/- what pages on a coincidence. Note the 3/3 streak hedge: a workaround for an ambiguous metric, not a fix.01-diagnose/funnel.sql- before touching a threshold, confirm the alarm caught its stated failure. Here it hadn't: input + similarities were healthy; the pool was quota-capped.02-instrument-the-emitter/- move the intelligence to the emitter.classify.pyis the whole idea in one pure function;matcher.pyshows the two counters that feed it.03-cause-aware-alarm/- the threshold is UNCHANGED. Only the log line got smarter. Plus an unalarmed metric for the benign nights.04-test-the-gate/… View on GitHub
TL;DR
| Alarma por umbral de salida | Alarma consciente de la causa | |
|---|---|---|
| Qué vigila | "¿el conteo llegó a 0?" | "¿el conteo llegó a 0 por una razón rota?" |
| Dónde vive la lógica | el umbral en el CDK | el emisor (el trabajo mismo) |
| Despertadas falsas | cada mes, en agenda | ninguna observada |
| Noches-0 benignas | indistinguibles de una falla | registradas como una métrica aparte, sin alarma |
| Arreglo cuando dispara en falso | subir el umbral → quedar ciego | ya está correcto |
| Costo nuevo de AWS | $0 (derivado de logs) | $0 (derivado de logs) |
Tres ideas hacen el trabajo
- Una alarma por conteo de salida confunde causas.
count == 0puede significar "roto" o "correctamente callado". Un solo umbral no los distingue. - Diagnostica antes de ajustar. Cuando una alarma dispara, primero pregunta si atrapó la falla que dice atrapar. Razona desde los datos, no desde la descripción de la alarma.
- Mueve la inteligencia al emisor. Instrumenta el pipeline para que emita por qué el conteo fue 0. Deja la alarma tonta; haz lista la línea de log.
La trampa: un conteo no es un diagnóstico
La alarma original era una métrica derivada de logs de manual. El trabajo loguea una línea cuando termina con 0 resultados; un metric filter la cuenta; la alarma despierta ante una racha sostenida:
# 00-output-threshold-alarm/emit.py (la versión ingenua)
if scanned_items and created == 0:
logger.warning("matcher_zero_results", items=len(scanned_items))
// 00-output-threshold-alarm/alarm.ts
new logs.MetricFilter(this, "ZeroResultsFilter", {
logGroup,
filterPattern: logs.FilterPattern.literal('"matcher_zero_results"'),
metricNamespace: "myapp/Jobs",
metricName: "MatcherZeroResults",
metricValue: "1",
defaultValue: 0,
});
new cw.Alarm(this, "ZeroResultsAlarm", {
metric: zeroResultsMetric,
threshold: 1,
evaluationPeriods: 3,
datapointsToAlarm: 3, // 3 noches seguidas
});
El evaluationPeriods: 3 ya es una cobertura: alguien sabía que una sola noche 0 era varianza normal y exigió una racha antes de despertar. Esa cobertura es la pista. Compra tiempo; no arregla la ambigüedad.
Si el conteo puede legítimamente quedarse en 0 por un rato, entonces "3 noches seguidas" no es "roto", es "ya esperamos tres noches". La alarma dispara de todos modos. Nada más te entrenaste a esperar más para la despertada falsa.
El problema real: created == 0 tiene (al menos) dos causas.
- Roto. Un umbral o un embedding regresó y nada pasa la barra ya, la clásica falla de "regresa 0 para siempre". Esto debería despertar, fuerte, rápido.
- Correctamente callado. El pool elegible es chico y cada candidato que podía emparejar ya lo hizo, o pegó contra una cuota mensual por usuario. El trabajo se está portando perfecto. Esto nunca debería despertar.
Paso 1: diagnostica antes de ajustar
El instinto cuando dispara una alarma "inestable" es subir el umbral o ensanchar la ventana. Resístelo. Primero responde una pregunta: ¿la alarma atrapó lo que dice que atrapó?
La propia descripción de la alarma culpaba a una regresión. Así que revisé las precondiciones de esa regresión directo, desde datos de producción, antes de tocar cualquier cosa:
-- 01-diagnose/funnel.sql: ¿está sano el input? (abreviado)
-- 1. ¿Hay items frescos para emparejar siquiera?
SELECT count(*) FROM items
WHERE status = 'ACTIVE' AND embedding IS NOT NULL
AND created_at > now() - interval '7 days';
-- sano: cientos
-- 2. ¿Los candidatos pasan el piso de similitud? (la barra "regresada")
-- Puntúa el item más nuevo contra el pool elegible.
SELECT max(1 - (u.embedding <=> :item_vec)) AS best_similarity
FROM eligible_users u;
-- sano: bien arriba del piso
-- 3. ¿Los usuarios elegibles ya fueron servidos este periodo?
SELECT tier, used, cap, count(*) n
FROM usage_counters
WHERE feature = 'matcher' AND period = :this_month
GROUP BY tier, used, cap
ORDER BY tier;
-- la respuesta
Los datos contaban una historia clara: input sano, similitudes sanas, y 9 de 11 usuarios servibles ya estaban en su tope mensual. Una ráfaga más temprano en el mes había consumido legítimamente la cuota del pool; las "0 noches" eran el pool sentado en su tope hasta el siguiente reinicio. Ninguna regresión. La alarma había disparado sobre un sistema correcto.
Este es el paso de carga estructural y el que más fácil se salta. La alarma me entregó una hipótesis ("regresión"); los datos la falsearon. Todo lo que viene después solo vale la pena hacerlo porque me detuve a revisar.
Regla de dedo: la descripción de una alarma es una hipótesis, no un diagnóstico. El primer trabajo de un humano de guardia (o de un agente) es confirmar que la alarma atrapó su falla declarada, no silenciar el síntoma.
Paso 2: nombra la falla con precisión
Antes de escribir código, escribe la oración que la alarma debería codificar. La mía:
Despierta cuando el trabajo escaneó input real y candidatos elegibles llegaron a la compuerta final con cuota de sobra, y aun así produjo 0 resultados.
Todo lo que esa oración excluye es, por construcción, un no-incidente:
- 0 porque ningún candidato llegó a la compuerta final → el pool ya estaba servido o inactivo. Callado.
- 0 porque cada candidato que llegó a la compuerta estaba bloqueado por cuota → demanda real, correctamente limitada por tasa. Callado.
- 0 porque candidatos llegaron a la compuerta, tenían cuota, y nada pasó → la barra está rota. Despierta.
Nota que esto es una afirmación sobre los estados internos del pipeline, no sobre el conteo de salida. Lo que significa que la métrica de salida nunca lo puede expresar. El emisor tiene que hacerlo.
Paso 3: mueve la inteligencia al emisor
La alarma se queda tonta, un umbral sobre un conteo. Hacemos lista la línea de log haciendo que el trabajo clasifique su propia corrida de 0 resultados. Dos contadores baratos, acumulados conforme corre el pipeline, capturan la distinción:
# 02-instrument-the-emitter/matcher.py (forma)
class NightlyMatcher:
def __init__(self, ...):
self._reached_gate = 0 # candidatos que pasaron los pre-filtros
self._quota_blocked = 0 # descartados SOLO por el tope por usuario
async def _process_item(self, item):
candidates = await self._prefilter(item) # similitud, dedup, ...
self._reached_gate += len(candidates) # llegaron a la compuerta
created = 0
for c in candidates:
if c.score < THRESHOLD:
continue # encaje débil
if not await self._has_quota(c.user_id):
self._quota_blocked += 1 # demanda real, en tope
continue
await self._create_match(item, c)
created += 1
return created
El clasificador es una función pura, trivial de razonar y de probar:
# 02-instrument-the-emitter/classify.py
def classify_zero_result(reached_gate: int, quota_blocked: int) -> str:
"""¿Por qué una corrida que escaneó input real creó 0 resultados?"""
if reached_gate == 0 or quota_blocked > 0:
return "quiet" # pool servido/inactivo, o la demanda pegó el tope
return "regression" # candidatos + cuota de sobra, nada pasó
Y la compuerta de emisión enruta a dos eventos de log distintos:
# 02-instrument-the-emitter/emit.py
if scanned_items and created_total == 0:
if classify_zero_result(self._reached_gate, self._quota_blocked) == "quiet":
logger.info("matcher_quiet_night", # solo observ., sin despertar
reached_gate=self._reached_gate,
quota_blocked=self._quota_blocked)
else:
logger.warning("matcher_zero_results", # la regresión real
reached_gate=self._reached_gate)
El metric filter de la alarma no cambia: sigue haciendo match a "matcher_zero_results". Pero esa línea ahora solo aparece para la falla genuina. En una noche callada el trabajo emite matcher_quiet_night en su lugar, que no hace match al patrón de la alarma, así que la métrica publica su defaultValue de 0 y la racha se rompe.
La despertada falsa desapareció no porque subimos un umbral, sino porque el emisor dejó de mentir sobre la causa.
Paso 4: mantén observable el camino benigno
"No despertar" no es "no registrar". Las noches calladas son una señal real: te dicen que el pool servible se está topando, lo cual es un dato de crecimiento, no un incidente.
Así que el evento benigno recibe su propia métrica, sin alarma:
// 03-cause-aware-alarm/quiet-metric.ts: visibilidad, no una despertada
new logs.MetricFilter(this, "QuietNightFilter", {
logGroup,
filterPattern: logs.FilterPattern.literal('"matcher_quiet_night"'),
metricNamespace: "myapp/Jobs",
metricName: "MatcherQuietNight",
metricValue: "1",
defaultValue: 0,
});
Ahora el dashboard muestra las dos líneas: regresiones (que despiertan) y noches calladas (que no). Cuando alguien pregunta después "¿por qué cayeron los matches en la segunda mitad del mes?", la respuesta es una gráfica, no una sesión forense de SQL.
Y si las noches calladas suben de manera sostenida, esa es la señal para subir los topes o ensanchar la elegibilidad, una decisión de producto que la alarma vieja sepultaba bajo un incidente falso.
Las lecciones del umbral
- No subas el umbral para silenciar una despertada falsa. Es el reflejo, y está al revés. Subirlo (o ensanchar la ventana) hace la alarma más lenta para atrapar la falla real mientras de todos modos termina disparando por la benigna. Cambias un falso negativo por un falso positivo demorado. Arregla la ambigüedad en su lugar; deja el umbral apretado.
- Una cobertura en la config de la alarma es un olor.
datapointsToAlarm: 3, evaluationPeriods: N, un sospechosamente redondo > 10: cada uno es a menudo un humano dándole la vuelta a una métrica ambigua. A veces es la decisión correcta (un solo throttle está bien; alarma sobre la ráfaga). Pero si la causa es ambigua, ninguna cantidad de contar rachas la resuelve. Pregúntate cuál de las dos tienes. - Alarma sobre la falla, mide el resto. Exactamente una señal debería despertar: el estado por el que despertarías a un humano. Todo lo de al lado - el cero benigno, el parpadeo reintentado, la demanda limitada por tasa - es una métrica sin acción de alarma. Despertar sobre estados ambiguos es como las alarmas terminan silenciadas.
Trampas
Los metric filters literales hacen match a subcadenas
Este me mordió. El FilterPattern.literal('"matcher_zero_results"') de CloudWatch hace match a cualquier evento de log que contenga esa cadena. Nombra el evento benigno matcher_quiet_night, no matcher_zero_results_ok: el segundo contiene el patrón de la alarma como subcadena y la dispararía de todos modos, derrotando en silencio todo el arreglo. Elige un token sin traslape.
defaultValue: 0 es lo que rompe la racha
El filter emite 0 por cada periodo sin línea que haga match. Ese es el mecanismo que le permite a una noche callada "publicar un 0" y reiniciar una alarma de datapoints consecutivos. Sin él, los datapoints faltantes dependen de treatMissingData y la lógica de racha se vuelve turbia. Consérvalo.
Prueba la compuerta, no la plomería
La prueba valiosa no es "¿sirve el metric filter?" (eso es trabajo de AWS). Es "¿el trabajo emite el evento correcto para cada estado interno?". Maneja el emisor y afirma la línea de log:
# 04-test-the-gate/test_emit.py
async def test_pages_only_on_regression():
m = NightlyMatcher(...)
m._process_item = fake(reached_gate=+3, quota_blocked=+0, created=0)
with structlog.testing.capture_logs() as logs:
await m.run()
events = [e["event"] for e in logs]
assert "matcher_zero_results" in events # regresión → despierta
assert "matcher_quiet_night" not in events
async def test_quiet_when_quota_capped():
m = NightlyMatcher(...)
m._process_item = fake(reached_gate=+3, quota_blocked=+3, created=0)
with structlog.testing.capture_logs() as logs:
await m.run()
events = [e["event"] for e in logs]
assert "matcher_quiet_night" in events # demanda en tope → callado
assert "matcher_zero_results" not in events
Una checklist reutilizable
Cuando una alarma dispara y sospechas que es ruido, antes de tocar un umbral:
- Confirma la falla declarada. ¿Los datos muestran la condición exacta que la alarma dice? Si no, la alarma está mal categorizando: un bug de diseño, no un problema de ajuste.
- Enumera las causas del valor de la métrica.
0,5xx,lento,profundidad de cola: lista cada razón distinta por la que llega al valor de la alarma. Si hay más de una, y difieren en si son incidentes, la métrica es ambigua. - Escribe la falla en una oración. En términos del estado interno del sistema, no de su salida. Si la
Comments
No comments yet. Start the discussion.