Zum Inhalt springen
Observability

Series temporales ausentes en PromQL: por qué la tasa de éxito desaparece durante una caída

Un servicio que solo devuelve errores se ve en Grafana igual que un servicio sin tráfico. La línea se corta, la alerta calla, y a la mañana siguiente te enteras de la caída por la llamada de un cliente. La causa no es una fuente de datos rota, sino la forma en que PromQL combina dos series temporales. Este artículo muestra el fallo en un dashboard construido sobre métricas de Spring Boot, después la corrección, y al final ambas cosas como Terraform para que no se pierdan otra vez con el siguiente clic.

Contenido

La noche en que nadie se dio cuenta de nada

El montaje es corriente: unos cuantos servicios Spring Boot en un namespace de Kubernetes llamado shop-prod, cada uno con spring-boot-starter-actuator y el puente de Micrometer hacia Prometheus. Tres de ellos importan aquí, catalog, checkout y payment. Para operaciones hay un dashboard de Grafana con un panel que muestra la tasa de éxito por servicio, y una alerta que salta por debajo del 99 por ciento.

A las 02:14 payment pierde la conexión con su base de datos. El servicio sigue en marcha, los pods están sanos, la readiness probe está satisfecha. Solo que responde a cada petición con un HTTP 500.

Por la mañana el dashboard muestra una noche tranquila. Las líneas de catalog y checkout corren planas arriba, al 100 por ciento. La línea de payment termina a las 02:14 y no vuelve. La alerta nunca disparó. La caída se descubrió a las 08:40 por un cliente que llamó porque no podía pagar.

La monitorización no calló porque no pasara nada. Calló porque todo estaba roto. Ambos estados se ven igual mientras la consulta esté construida de forma ingenua.

El primer intento: la consulta que todo el mundo escribe primero

Así se veía el panel. Peticiones correctas divididas entre todas las peticiones, agrupadas por servicio:

sum(rate(http_server_requests_seconds_count{namespace="shop-prod", status!~"5.."}[5m])) by (job)
/
sum(rate(http_server_requests_seconds_count{namespace="shop-prod"}[5m])) by (job)

Se lee correcto, y en operación normal lo es. http_server_requests_seconds_count es el contador que hay detrás del timer de Micrometer http.server.requests, que Spring Boot incrementa por cada petición que atiende. rate() lo convierte en peticiones por segundo, y sum(...) by (job) agrega sobre todos los pods de un servicio. El filtro status!~"5.." deja pasar todo lo que no sea un error de servidor.

Agrupar por job no es casualidad. Prometheus asigna a cada pod su propia etiqueta instance al recolectar. Sin sum ... by (job) el panel recibiría una línea por pod, y con tres réplicas la afirmación sobre el servicio ya quedaría diluida antes de que el fallo real llegue siquiera a golpear.

Ese fallo está en otra parte, y solo se manifiesta cuando payment devuelve exclusivamente 500.

Por qué se corta la línea: cómo PromQL combina dos series

En PromQL una serie temporal existe solo mientras tenga puntos de datos. No cae a cero, se acaba. Tras unos cinco minutos sin un punto nuevo se considera obsoleta y deja de aparecer en cualquier resultado.

A partir de las 02:14 no llega ni una sola petición de payment con un estado que no empiece por 5. Con eso ya no queda serie para el numerador por encima de la raya de fracción. Por debajo sí, porque ahí los 500 siguen contando.

En una división Prometheus no calcula simplemente números. Para cada serie de la izquierda busca una serie de la derecha con un conjunto de etiquetas idéntico. Solo donde ambos lados encajan aparece un resultado. Si falta un lado, no aparece resultado, y no un resultado con valor cero, sino ninguno en absoluto.

Antes de 02:14                       Después de 02:14
Numerador:   payment -> 42.0         Numerador:   (sin serie)
Denominador: payment -> 42.0         Denominador: payment -> 41.8
Coincide:    payment -> 1.0          Coincide:    (sin serie)

Para el gráfico eso significa: sin serie, sin línea. Grafana no puede saber que detrás del hueco se esconde una caída total. Recibe la misma respuesta vacía que para un servicio que sencillamente no tiene tráfico de noche.

Aquí está la confusión cara: una caída y una noche tranquila se ven igual.

El reflejo que lo empeora: 1 menos la tasa de error

Una vez entendido eso, la primera ocurrencia suele ser esta:

1 - (
  sum(rate(http_server_requests_seconds_count{namespace="shop-prod", status=~"5.."}[5m])) by (job)
  /
  sum(rate(http_server_requests_seconds_count{namespace="shop-prod"}[5m])) by (job)
)

En lugar de contar los aciertos, esta versión cuenta los errores y da la vuelta al resultado. Durante la caída funciona de verdad, porque entonces la serie de los 500 sí existe.

El fallo solo se ha mudado. Mientras todo esté sano no hay ni una petición con estado 5xx, así que la serie del numerador no existe, así que no aparece resultado. Ahora la línea falta en operación normal y solo asoma cuando se produce el primer error.

De las dos, esta es la peor variante. Un panel que solo muestra algo cuando hay problemas parece averiado en el día a día, y a un panel averiado nadie le hace caso al cabo de dos semanas.

Ambas versiones fallan en el mismo punto: un lado de la división puede desaparecer, y con él desaparece el resultado.

La serie cero: tomar las etiquetas que faltan del denominador

La solución consiste en poner junto al numerador una serie sustituta que entre cuando falte la auténtica. Esa sustituta tiene que cumplir dos condiciones: su valor debe ser cero, y debe llevar exactamente las etiquetas por las que se agrupa.

El denominador aporta ambas cosas si se multiplica por cero:

(
  sum(rate(http_server_requests_seconds_count{namespace="shop-prod", status!~"5.."}[5m])) by (job)
  or
  sum(rate(http_server_requests_seconds_count{namespace="shop-prod"}[5m])) by (job) * 0
)
/
sum(rate(http_server_requests_seconds_count{namespace="shop-prod"}[5m])) by (job)

El operador or trabaja por conjunto de etiquetas, no por expresión. Para cada servicio que aporta el lado izquierdo, toma el lado izquierdo. Para cada servicio que solo conoce el lado derecho, toma el derecho. Mientras payment tenga peticiones correctas, gana el valor real. Si el servicio cae, queda el cero.

Lo decisivo es de dónde procede la serie sustituta. Sale de la misma métrica con la misma agrupación, así que lleva por fuerza las mismas etiquetas. Con eso la división vuelve a encontrar su pareja, y el resultado es una línea que cae a cero en lugar de desaparecer.

De paso queda resuelto un caso en el que se piensa pocas veces: un servicio recién desplegado cuyas primeras peticiones fallan todas. Sin serie cero no aparece nunca en el panel, con ella está a cero desde el primer momento.

Por qué vector(0) no basta aquí

En muchas respuestas figura or vector(0) como solución, y para un panel de tipo stat es correcto. Para este panel no.

sum(rate(http_server_requests_seconds_count{namespace="shop-prod", status!~"5.."}[5m])) by (job)
or vector(0)

vector(0) genera una serie sin etiqueta alguna. Por tanto no puede entrar en lugar de payment, porque no sabe que payment existe. Simplemente se suma al resultado como una fila anónima, y en la división no encuentra pareja con un conjunto de etiquetas que encaje. El panel gana una serie más y sigue sin línea para el servicio caído.

vector(0) responde a la pregunta de si hay algún resultado. La serie cero del denominador responde a la pregunta de qué servicios existen y cómo le va a cada uno.

Quien por otros motivos quiera quedarse con vector(0), tiene que darle las etiquetas a mano:

label_replace(vector(0), "job", "payment", "", "")

Eso funciona, pero exige que cada nombre de servicio esté fijo en la consulta. Con el siguiente servicio nuevo faltará, y nadie se dará cuenta. Por eso el camino a través del denominador es el más sólido: conoce los servicios porque acaba de contarlos él mismo.

El mismo fallo en la alerta, solo que más caro

En el dashboard la serie ausente cuesta un hueco en el gráfico. En una regla de alerta cuesta la alarma.

sum(rate(http_server_requests_seconds_count{namespace="shop-prod", status!~"5.."}[5m])) by (job)
/
sum(rate(http_server_requests_seconds_count{namespace="shop-prod"}[5m])) by (job)
< 0.99

Esta regla no puede dispararse en una caída total. La comparación < 0.99 filtra series: lo que queda se convierte en alarma. Si no hay serie, no queda nada, y Prometheus interpreta un resultado vacío como que todo está en orden. Cuanto más completa sea la caída, con más seguridad calla la regla.

En eso este fallo resulta especialmente desagradable. No se esconde en el caso límite, golpea cuando importa. Un servicio con errores esporádicos dispara obedientemente. Un servicio que ha desaparecido del todo, no.

Con la serie cero en el numerador la serie se mantiene, cae a cero y queda por debajo del umbral. La regla dispara.

El límite: cuando el denominador también desaparece

La serie cero salva el caso en el que un servicio responde y al hacerlo devuelve errores. No salva el caso en el que deja de responder por completo.

Si se terminan todos los pods de payment, ya no queda nadie que pueda contar nada. Prometheus no encuentra destino y la métrica desaparece por completo. Entonces falta también el denominador, y de él ya no se puede derivar ninguna serie cero. El panel se enfrenta al mismo vacío otra vez, un nivel más abajo.

Para eso hace falta una segunda regla que no pregunte por un valor sino por la existencia:

absent(
  sum(rate(http_server_requests_seconds_count{namespace="shop-prod", job="payment"}[5m]))
)

absent() devuelve un resultado justo cuando la expresión interior no devuelve ninguno. El precio es que el nombre del servicio tiene que estar fijo en la consulta, porque una ausencia no se puede agrupar por etiquetas que en ese momento no existen.

En la práctica eso significa: para los servicios cuya caída duele de verdad hay una regla con nombre propio. Para el resto carga con ello la tasa de éxito con serie cero. Quien confunde ambas cosas se queda o bien con una regla que calla en la emergencia, o bien con una lista de nombres de servicio que nadie mantiene.

Un tercer camino pasa por la métrica up, que Prometheus escribe por sí mismo para cada destino. Dice si la recolección funcionó y reacciona por tanto antes que cualquier cifra funcional. A cambio no dice nada sobre si el servicio da respuestas sensatas. Solo las tres juntas dan una imagen completa.

Fallo vecino 1: éxito no es lo mismo que SUCCESS

Junto a status, Micrometer aporta una segunda etiqueta que aparentemente simplifica el asunto. outcome resume el código de estado en una categoría, y echar mano de ella resulta tentador:

sum(rate(http_server_requests_seconds_count{namespace="shop-prod", outcome="SUCCESS"}[5m])) by (job)
/
sum(rate(http_server_requests_seconds_count{namespace="shop-prod"}[5m])) by (job)

Se lee más limpio que la expresión regular y mide algo distinto de lo que la mayoría espera. outcome conoce cinco valores, y SUCCESS representa exclusivamente 2xx. Todo lo demás cae fuera del numerador: INFORMATIONAL para 1xx, REDIRECTION para 3xx, CLIENT_ERROR para 4xx y SERVER_ERROR para 5xx.

Con eso cada redirección cuenta como no-éxito. Un endpoint que redirige a la página de resultado tras un POST hunde la tasa aunque haga justo lo que debe. Lo mismo vale para 304 Not Modified, que es completamente normal en un cliente con la caché funcionando. Un dashboard que de pronto marca 88 por ciento tras un despliegue, sin que se haya producido un solo error, suele tener esta causa.

Lo correcto es filtrar por aquello que realmente se quiere excluir:

sum(rate(http_server_requests_seconds_count{namespace="shop-prod", outcome!="SERVER_ERROR"}[5m])) by (job)

Esta versión es además más sólida que status!~"5..", porque no depende de que en status haya siempre un número de tres cifras. En conexiones abortadas puede aparecer ahí un valor no numérico según el entorno, y una expresión regular sobre 5.. no lo alcanza.

Queda la pregunta de qué hacer con los 4xx. Contarlos como éxito es defendible, porque una petición inválida no es una caída del servicio. Aun así debería ser una decisión consciente, porque una autenticación rota produce 401 en serie y permanece invisible en este recuento. Quien quiera verlo por separado construye un segundo panel sobre outcome="CLIENT_ERROR" en lugar de sobrecargar la tasa de éxito.

Fallo vecino 2: el panel se queda vacío al hacer zoom

El tercer hallazgo en el mismo dashboard afecta al rango entre corchetes. En varios paneles ponía:

rate(http_server_requests_seconds_count{namespace="shop-prod"}[$__interval])

$__interval es la distancia entre dos puntos que Grafana calcula a partir de la ventana temporal y el ancho del panel. En siete días son varios minutos y todo se ve bien. Si alguien hace zoom hasta una hora, el valor se encoge a unos pocos segundos. Pero rate() necesita al menos dos puntos de datos dentro de la ventana, si no, no puede formar una pendiente. En cuanto la ventana se hace menor que la distancia entre dos mediciones, la consulta no devuelve nada.

Lo traicionero es el momento. El fallo no se nota nunca mientras se construye el dashboard, porque ahí se mira sobre todo en días. Se nota en el instante en que alguien hace zoom a los últimos minutos durante un incidente, y es entonces cuando el panel debería mostrar algo.

Lo correcto es:

rate(http_server_requests_seconds_count{namespace="shop-prod"}[$__rate_interval])

$__rate_interval se define como max($__interval + intervalo de scrape, 4 * intervalo de scrape) y por tanto nunca es demasiado pequeño. El valor del intervalo de scrape sale del campo Min step de la consulta, en su defecto del ajuste Scrape interval de la fuente de datos, cuyo valor por defecto son 15 segundos.

Aquí hay una trampa que deja el cambio sin efecto: si la fuente de datos supone 15 segundos mientras las métricas se recolectan en realidad cada minuto, la macro calcula con una resolución demasiado fina y la ventana sigue siendo pequeña. Cambiar a $__rate_interval no ayuda entonces. Así que antes de cambiar, mira primero qué hay configurado en la fuente de datos y con qué cadencia llegan los datos de verdad.

La consulta completa

Las tres correcciones juntas, esta es la versión que va al panel:

(
  sum(rate(http_server_requests_seconds_count{namespace="shop-prod", outcome!="SERVER_ERROR"}[$__rate_interval])) by (job)
  or
  sum(rate(http_server_requests_seconds_count{namespace="shop-prod"}[$__rate_interval])) by (job) * 0
)
/
sum(rate(http_server_requests_seconds_count{namespace="shop-prod"}[$__rate_interval])) by (job)

Tres cosas cambian respecto al primer intento: el filtro por outcome en lugar de una expresión regular sobre status, la serie cero detrás del or, y $__rate_interval en lugar de $__interval. La consulta queda más larga y se lee peor. A cambio, en una caída muestra un cero y no la nada.

Todo como código: primero la versión ingenua en Terraform

Un dashboard creado a mano en la interfaz es la segunda mitad del problema. La corrección de arriba vive justo hasta que alguien duplica el panel, cambia el namespace y se deja la serie cero por el camino.

Así que a Terraform con ello. El primer intento evidente tiene este aspecto, y tiene un fallo:

terraform {
  required_providers {
    grafana = {
      source = "grafana/grafana"
    }
    google = {
      source = "hashicorp/google"
    }
  }
}

locals {
  tasa_exito_query = <<-EOT
    (
      sum(rate(http_server_requests_seconds_count{namespace="shop-prod", outcome!="SERVER_ERROR"}[$__rate_interval])) by (job)
      or
      sum(rate(http_server_requests_seconds_count{namespace="shop-prod"}[$__rate_interval])) by (job) * 0
    )
    /
    sum(rate(http_server_requests_seconds_count{namespace="shop-prod"}[$__rate_interval])) by (job)
  EOT
}

Una consulta, definida en un solo sitio, utilizable por dashboard y alerta a la vez. La idea es la correcta y aun así está mal ejecutada.

$__rate_interval es una macro de Grafana. Grafana la sustituye por un valor concreto al ejecutar la consulta. Cloud Monitoring no la conoce. Si esa misma cadena viaja a una regla de alerta, allí queda un rango que el parser no puede resolver y la regla está rota. En el mejor caso ya falla el terraform apply, en el peor queda en la nube una regla que no evalúa nunca.

A eso se suma el namespace cableado a fuego, tres veces dentro de la misma cadena. Al copiarlo para el entorno de staging se olvida una de las tres apariciones, y la consulta mezcla dos entornos.

Una consulta, dos destinos: la corrección en Terraform

Ambos problemas se resuelven con una plantilla de marcadores que se rellena dos veces de forma distinta:

locals {
  namespace = "shop-prod"

  # %[1]s es el namespace, %[2]s el rango para rate().
  # El rango es el único punto en el que dashboard y alerta pueden
  # diferir, porque $__rate_interval solo existe en Grafana.
  tasa_exito_plantilla = <<-EOT
    (
      sum(rate(http_server_requests_seconds_count{namespace="%[1]s", outcome!="SERVER_ERROR"}[%[2]s])) by (job)
      or
      sum(rate(http_server_requests_seconds_count{namespace="%[1]s"}[%[2]s])) by (job) * 0
    )
    /
    sum(rate(http_server_requests_seconds_count{namespace="%[1]s"}[%[2]s])) by (job)
  EOT

  tasa_exito_dashboard = format(local.tasa_exito_plantilla, local.namespace, "$__rate_interval")
  tasa_exito_alerta    = format(local.tasa_exito_plantilla, local.namespace, "5m")
}

format() con los índices %[1]s y %[2]s permite insertar el mismo valor varias veces sin pasarlo varias veces. El namespace queda así en un único sitio.

Dos detalles que suelen morder en el primer intento. Primero: en HCL, ${ inicia una interpolación. Las llaves de los selectores de etiqueta son inofensivas porque no las precede ningún signo de dólar, y $__rate_interval también lo es porque tras el signo de dólar no viene ninguna llave. Segundo: en cuanto entra format() en juego, cada signo de porcentaje de la plantilla es un verbo de formato. En esta consulta no aparece ninguno, en una consulta con porcentajes en el texto de la leyenda sí.

El dashboard como recurso

resource "grafana_folder" "shop" {
  title = "Shop Producción"
  uid   = "shop-prod"
}

resource "grafana_dashboard" "tasa_exito" {
  folder    = grafana_folder.shop.uid
  overwrite = true

  config_json = jsonencode({
    uid      = "shop-tasa-exito"
    title    = "Shop, tasa de éxito por servicio"
    timezone = "browser"
    time     = { from = "now-24h", to = "now" }

    panels = [
      {
        type    = "timeseries"
        title   = "Tasa de éxito por servicio"
        gridPos = { h = 10, w = 24, x = 0, y = 0 }

        fieldConfig = {
          defaults = {
            unit = "percentunit"
            min  = 0
            max  = 1
            custom = {
              spanNulls   = false
              lineWidth   = 2
              fillOpacity = 5
            }
            thresholds = {
              mode = "absolute"
              steps = [
                { color = "red", value = null },
                { color = "green", value = 0.99 },
              ]
            }
          }
          overrides = []
        }

        targets = [
          {
            refId        = "A"
            expr         = local.tasa_exito_dashboard
            legendFormat = "{{job}}"
            interval     = "1m"
          },
        ]
      },
    ]
  })
}

Dos ajustes ahí dentro importan más de lo que aparentan.

spanNulls = false hace que Grafana no salve los huecos reales. Puesto a true, la representación traza una línea recta sobre un periodo sin datos, y la caída desaparece visualmente una segunda vez, esta vez en el dibujo en lugar de en la consulta. Un panel que debe mostrar la emergencia no puede interpolar.

interval = "1m" es el campo Min step de la interfaz. Fija el límite inferior de la resolución y es por tanto el valor con el que calcula $__rate_interval. Si las métricas se recolectan cada minuto, aquí es donde eso pertenece, si no, cambiar a $__rate_interval se queda sin efecto.

La regla de alerta en Cloud Monitoring

La regla recibe la misma consulta, solo que con rango fijo:

resource "google_monitoring_alert_policy" "tasa_exito" {
  display_name = "Tasa de éxito por debajo del 99 por ciento (shop-prod)"
  combiner     = "OR"

  conditions {
    display_name = "Tasa de éxito bajo el umbral"

    condition_prometheus_query_language {
      query               = "(${trimspace(local.tasa_exito_alerta)}) < 0.99"
      duration            = "300s"
      evaluation_interval = "60s"
      alert_rule          = "ShopTasaExitoBaja"
      rule_group          = "shop-prod"
    }
  }

  alert_strategy {
    auto_close = "1800s"
  }
}

resource "google_monitoring_alert_policy" "payment_sin_datos" {
  display_name = "payment ha dejado de reportar datos por completo"
  combiner     = "OR"

  conditions {
    display_name = "No hay serie temporal"

    condition_prometheus_query_language {
      query               = "absent(sum(rate(http_server_requests_seconds_count{namespace="${local.namespace}", job="payment"}[5m])))"
      duration            = "600s"
      evaluation_interval = "60s"
      alert_rule          = "ShopPaymentSinDatos"
      rule_group          = "shop-prod"
    }
  }

  alert_strategy {
    auto_close = "1800s"
  }
}

Notas sobre los campos que cuestan tiempo la primera vez:

evaluation_interval tiene que ser un múltiplo positivo de 30 segundos. La API rechaza otros valores, y el mensaje de error del terraform apply no lo expresa con especial claridad.

duration es el tiempo que la condición debe cumplirse de forma continua antes de que la alarma pase de pendiente a disparada. Sin indicación vale cero, y entonces basta un solo pico para dispararla. Cinco minutos son un punto de partida razonable para una tasa de éxito, porque un rate() sobre cinco minutos reacciona despacio de todos modos.

Los paréntesis alrededor de la consulta insertada no son adorno. La plantilla termina en una división, y la comparación debe actuar sobre el resultado completo, no solo sobre el denominador. Aritméticamente la división liga más fuerte de todas formas, pero una consulta cuya corrección depende de conocer la precedencia de operadores es una consulta que se rompe en la siguiente reforma. trimspace() elimina de paso el salto de línea que deja el heredoc al final.

La segunda regla cubre el caso de la sección sobre el límite. Su duration es deliberadamente mayor, porque un despliegue puede provocar brevemente que no lleguen datos, y por eso no hay que despertar a nadie de noche.

Provocar la caída antes de que llegue sola

El fallo de verdad en esta historia no fue la consulta. El fallo de verdad fue que nadie llegó a producir nunca el estado para el que se construyó el dashboard.

En un servicio Spring Boot basta con un filtro que va detrás de una propiedad y que en operación normal ni siquiera llega a existir como bean:

package com.ejemplo.shop.caida;

import jakarta.servlet.Filter;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.ServletRequest;
import jakarta.servlet.ServletResponse;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.stereotype.Component;

import java.io.IOException;

@Component
@ConditionalOnProperty(name = "caida.activa", havingValue = "true")
class FiltroCaida implements Filter {

    @Override
    public void doFilter(ServletRequest peticion, ServletResponse respuesta, FilterChain cadena)
            throws IOException, ServletException {

        String ruta = ((HttpServletRequest) peticion).getRequestURI();

        // El actuator tiene que seguir accesible, si no la métrica desaparece
        // por completo y el test comprueba el caso equivocado.
        if (ruta.startsWith("/actuator")) {
            cadena.doFilter(peticion, respuesta);
            return;
        }

        HttpServletResponse http = (HttpServletResponse) respuesta;
        http.setStatus(HttpServletResponse.SC_INTERNAL_SERVER_ERROR);
        http.getWriter().write("Caída activa");
    }
}

La excepción para /actuator es la parte que se olvida en el primer intento, y decide si el test dice algo o no. Si el filtro estrangula también el endpoint de métricas, Prometheus ya no puede recolectar nada. Entonces la métrica desaparece por completo y estás observando el caso de la sección sobre el límite en lugar del caso del que trata esto. Ambos se ven igual en el panel pero tienen causas distintas y remedios distintos.

La caída se activa mediante la propiedad, sin imagen nueva:

kubectl set env deployment/payment CAIDA_ACTIVA=true -n shop-prod

Después esperar cinco minutos y mirar el dashboard. Con la consulta antigua la línea se corta. Con la nueva cae a cero, y la alerta pasa de pendiente a disparada tras los cinco minutos configurados. Se deshace con el mismo comando y CAIDA_ACTIVA=false.

Quien quiera arreglárselas sin código adicional, que le quite la base de datos al servicio. Eso es lo más parecido al incidente nocturno, porque así se producen los errores que el servicio genera realmente en una emergencia. A cambio se dosifica peor, y en un entorno compartido lo notan otros.

Fijar la regla con un test

Para que la corrección sobreviva a una reforma, tiene que ir a un test. promtool trae todo lo necesario y no requiere un Prometheus en marcha.

Primero la regla en un fichero propio, shop-alerts.yml:

groups:
  - name: shop-prod
    rules:
      - alert: ShopTasaExitoBaja
        expr: |
          (
            sum(rate(http_server_requests_seconds_count{namespace="shop-prod", outcome!="SERVER_ERROR"}[5m])) by (job)
            or
            sum(rate(http_server_requests_seconds_count{namespace="shop-prod"}[5m])) by (job) * 0
          )
          /
          sum(rate(http_server_requests_seconds_count{namespace="shop-prod"}[5m])) by (job)
          < 0.99
        for: 5m

Y el test, shop-alerts-test.yml:

rule_files:
  - shop-alerts.yml

evaluation_interval: 1m

tests:
  - interval: 1m
    input_series:
      # Diez minutos sano, después no llega ningún punto para los aciertos.
      - series: 'http_server_requests_seconds_count{namespace="shop-prod", job="payment", outcome="SUCCESS", status="200"}'
        values: '0+60x10 _ _ _ _ _ _ _ _ _ _'
      # A partir del minuto 10 solo cuentan los errores de servidor.
      - series: 'http_server_requests_seconds_count{namespace="shop-prod", job="payment", outcome="SERVER_ERROR", status="500"}'
        values: '0x10 0+60x10'

    promql_expr_test:
      - expr: |
          (
            sum(rate(http_server_requests_seconds_count{namespace="shop-prod", outcome!="SERVER_ERROR"}[5m])) by (job)
            or
            sum(rate(http_server_requests_seconds_count{namespace="shop-prod"}[5m])) by (job) * 0
          )
          /
          sum(rate(http_server_requests_seconds_count{namespace="shop-prod"}[5m])) by (job)
        eval_time: 20m
        exp_samples:
          - labels: '{job="payment"}'
            value: 0

    alert_rule_test:
      - eval_time: 20m
        alertname: ShopTasaExitoBaja
        exp_alerts:
          - exp_labels:
              job: payment

La sintaxis de valores es compacta y resulta rara en la primera lectura. 0+60x10 genera once puntos que empiezan en cero y aumentan de 60 en 60, es decir un contador a 60 peticiones por minuto. El guion bajo representa un punto de datos ausente, y de eso trata esto: a partir del minuto 10 la serie de aciertos ya no entrega nada mientras la serie de errores echa a andar.

Se ejecuta con:

promtool test rules shop-alerts-test.yml

El promql_expr_test es el más importante de los dos bloques. Comprueba que en el minuto 20 siga existiendo siquiera una muestra con la etiqueta job="payment", y que su valor sea cero. Esa es la propiedad que la consulta ingenua no tiene. Si se quita otra vez la serie cero, el test falla con un conjunto de resultados vacío, y lo hace antes de que alguien dependa de ello a las 02:14.

La llamada encaja en cualquier pipeline y no necesita ni clúster ni base de datos. Es la parte más barata de todo este artículo y la que más dura.

Los patrones de un vistazo

Caso Mal Bien
Tasa de éxito, el servicio solo devuelve errores aciertos / total (aciertos or total * 0) / total
Tasa de éxito, pensada al revés 1 - (errores / total) la misma serie cero, si no falta la línea estando sano
Valor sustituto sin agrupación or vector(0) or <denominador> * 0, porque solo ese lleva las etiquetas
Valor sustituto con nombres fijos label_replace(vector(0), ...) viable, pero cada servicio nuevo hay que añadirlo a mano
Servicio desaparecido del todo solo la tasa de éxito además absent(...) como regla propia
Definir el éxito outcome="SUCCESS" outcome!="SERVER_ERROR", si no los 3xx cuentan como error
Varios pods por servicio selector sin agrupación sum(...) by (job), si no una línea por instancia
Rango para rate() [$__interval] [$__rate_interval], más Min step acorde a la cadencia
Huecos en el gráfico spanNulls = true spanNulls = false, si no el hueco queda tapado
Consulta en dos sitios una para Grafana, otra tecleada para la alerta una plantilla, dos rellenos con format()

Cuándo una serie ausente no es un problema

No hay que cerrar todos los huecos, y quien pone or ... * 0 en cada consulta se compra problemas nuevos.

En una métrica con muchos valores de etiqueta posibles, la serie cero genera una línea para cada uno de ellos, incluidos los que llevan semanas sin hacer nada. Desglosa la tasa de éxito por uri en lugar de por job y tienes el problema al momento: un panel ordenado se convierte en treinta líneas a cero, una por cada endpoint que nadie ha llamado desde la última entrega. Aquí el hueco es la representación más honesta.

Lo mismo vale para contadores que se ejecutan raramente a propósito, por ejemplo una conciliación nocturna. Ahí la ausencia de la serie es la afirmación correcta. Un cero sostendría que se ha medido que no pasa nada. En realidad solo es que no se midió nada. Eso es una diferencia, y en un panel que alguien lee a las tres de la madrugada es una diferencia importante.

La pregunta que lo decide es esta: ¿la ausencia de datos es aquí un estado normal o un hallazgo? Si es normal, el hueco se queda. Si es un hallazgo, tiene que hacerse visible, ya sea como cero o mediante absent().

FAQ

¿Por qué una serie de Prometheus no cae simplemente a cero?
Porque Prometheus no sabe que debería caer. Un contador existe solo mientras algo lo escriba. Si dejan de llegar puntos de datos, la serie se considera obsoleta al cabo de unos cinco minutos y desaparece de cualquier resultado. Un cero sería una afirmación sobre la realidad, y Prometheus no puede hacerla si ya nadie mide.

¿Qué hace exactamente or en PromQL?
Trabaja por conjunto de etiquetas, no por expresión. Para cada combinación de etiquetas que aporta el lado izquierdo, vale el lado izquierdo. Todos los conjuntos que solo aparecen a la derecha se añaden. Por eso or funciona como valor sustituto solo si el lado derecho lleva las mismas etiquetas que el izquierdo.

¿No basta con or vector(0)?
Para un panel de tipo stat que muestra un único número, sí. Para un panel agrupado por servicio no, porque vector(0) no tiene etiquetas y por tanto no puede entrar en lugar de ningún servicio concreto. Aparece como una serie anónima adicional y no cambia nada respecto a la línea que falta.

¿Cómo detecto que una regla de alerta tiene este problema?
La pregunta de control es: supongamos que el estado vigilado se produce por completo. ¿Sigue existiendo la serie? En toda regla cuya expresión contenga una división o un filtro sobre un valor de etiqueta, merece la pena preguntárselo. Se puede responder de forma fiable con un test de promtool en el que la serie termine en guiones bajos.

¿Debo filtrar por status o por outcome?
Por outcome, pero por exclusión y no por inclusión. outcome!="SERVER_ERROR" significa justo lo que una tasa de éxito debe significar. outcome="SUCCESS" solo captura 2xx y cuenta por tanto cada redirección como un fracaso.

¿Por qué $__rate_interval no aporta nada con algunas fuentes de datos?
Porque la macro calcula con el intervalo de scrape guardado en la fuente de datos o en el campo Min step. Si ahí pone 15 segundos mientras las métricas se recolectan cada minuto, la ventana calculada sigue siendo demasiado pequeña. El valor tiene que corresponder a la cadencia real, si no el cambio no altera nada.

¿Tengo que rehacer ahora todos los paneles?
No. Están afectadas las expresiones en las que un lado de una división o de una comparación puede desaparecer, típicamente por un filtro sobre un valor de etiqueta como el código de estado. Un simple sum(rate(...)) sin ese filtro no tiene el problema.

Conclusión

Un dashboard solo está comprobado cuando alguien ha producido la caída y ha mirado si la muestra. Antes de eso solo sabes que se ve bien en operación normal, y esa es la única situación en la que nadie está mirando.

Las tres correcciones de este artículo cuestan media hora en conjunto: la serie cero del denominador, el filtro por outcome en lugar de una expresión regular, y $__rate_interval con un Min step acorde. La cuarta medida es la que mantiene unido el resto, y es la que menos cuesta: activar la caída, esperar cinco minutos, mirar.

El siguiente paso concreto: coge la regla de alerta que más te importe y escribe un test de promtool en el que la serie vigilada se corte a mitad de la ejecución. Si la regla no dispara, ya has encontrado una.

Fuentes

Todas las consultas, ejemplos de Terraform y fragmentos de código de este artículo son propios.

$ lang DE EN ES