Tu pipeline despliega y sabe volver atrás, pero es ciego. El smoke test de la 07-03 comprueba que el servicio respondió bien durante los treinta segundos posteriores al despliegue; sobre lo que pasa a los veinte minutos, cuando entra tráfico real, no dice nada. Y hay una pregunta todavía más incómoda que tampoco sabes contestar: ¿está el pipeline mejorando las cosas? ¿Cuántas veces has desplegado esta semana? ¿Cuánto tarda un cambio desde el commit hasta producción? ¿Qué porcentaje de despliegues acaba en rollback? Sin esos números, el pipeline es un acto de fe.

En este laboratorio construyes las dos direcciones del bucle de retroalimentación. Hacia dentro del sistema: instrumentarás Mini-Reservalia para que exponga métricas, levantarás Prometheus y Grafana con docker compose, definirás un SLO con su presupuesto de error calculado a mano, escribirás una alerta por síntoma y la dispararás a propósito. Hacia el pipeline: marcarás los despliegues en el panel para poder correlacionar "desplegamos" con "empeoró", calcularás las cuatro métricas DORA de tu propio repositorio con un job programado, y —el cierre del bucle— harás que el cd.yml observe las métricas después de desplegar y dispare el rollback él solo si la cosa se tuerce.

Contenido

  1. Objetivo, requisitos previos y punto de partida
  2. Instrumentar el servidor: contadores e histogramas
  3. El endpoint /metricas en formato Prometheus
  4. Las cuatro señales de oro sobre estas métricas
  5. Prometheus y Grafana con docker compose
  6. El panel como código
  7. El SLO y su presupuesto de error, con la aritmética
  8. Reglas de alerta por síntoma
  9. Disparar la alerta a propósito
  10. Marcar los despliegues en el panel
  11. Cerrar el bucle: las cuatro DORA de tu repositorio
  12. Rollback automático por métricas
  13. Verificación final
  14. Errores Comunes y Consejos
  15. Ejercicios
  16. Conclusión

  1. Objetivo, requisitos previos y punto de partida

Objetivo. Al terminar tendrás Mini-Reservalia exponiendo métricas en formato Prometheus, un panel de Grafana versionado en el repositorio, un SLO con presupuesto de error, una alerta que has visto dispararse, un informe semanal automático con las cuatro métricas DORA de tu repositorio, y un cd.yml que revierte solo cuando las métricas empeoran tras un despliegue.

Requisitos previos. Las lecciones 07-01 a 07-03. Docker y docker compose funcionando. El cd.yml desplegando y el rollback.yml operativo.

Punto de partida. Mini-Reservalia desplegada por el pipeline en staging (puerto 3001) y produccion (puerto 3002).

git checkout main && git pull
git checkout -b observabilidad

  1. Instrumentar el servidor: contadores e histogramas

Necesitamos dos tipos de métrica y conviene entender la diferencia antes de escribir código:

Tipo Qué es Ejemplo aquí Cómo se consulta
Counter Número que solo sube; se reinicia al reiniciar el proceso Peticiones totales por ruta y código rate() sobre una ventana
Gauge Número que sube y baja Peticiones en vuelo, segundos activo Valor directo
Histogram Contadores acumulativos por "cubo" de valor, más suma y total Duración de las peticiones histogram_quantile() para percentiles

Un contador de latencia media sería inútil: la media esconde exactamente lo que importa. Si 99 peticiones tardan 10 ms y una tarda 5 segundos, la media es 60 ms y parece estupenda, mientras un usuario de cada cien se está yendo. El histograma permite preguntar por el percentil 95 o 99, que es donde vive el dolor real. La 03-06 lo explicaba; ahora lo vas a implementar.

src/metricas.js:

// src/metricas.js
// Registro de metricas en formato Prometheus, sin dependencias.
//
// Prometheus es texto plano: cada linea es
//   nombre{etiqueta="valor",...} numero
// precedida de comentarios # HELP y # TYPE. Con eso basta para que
// Prometheus lo raspe (scrape) y lo almacene como serie temporal.

/** Limites de los cubos del histograma, en segundos. */
const CUBOS = [0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10];

export class Metricas {
  /** Contadores: clave = "nombre|etiquetas serializadas" -> numero */
  #contadores = new Map();
  /** Histogramas: clave -> { cubos: number[], suma: number, total: number } */
  #histogramas = new Map();
  #inicio = Date.now();
  #enVuelo = 0;

  #clave(nombre, etiquetas) {
    const partes = Object.entries(etiquetas)
      .sort(([a], [b]) => a.localeCompare(b))
      .map(([k, v]) => `${k}="${String(v).replace(/["\\\n]/g, '_')}"`);
    return `${nombre}|${partes.join(',')}`;
  }

  incrementar(nombre, etiquetas = {}, cantidad = 1) {
    const clave = this.#clave(nombre, etiquetas);
    this.#contadores.set(clave, (this.#contadores.get(clave) ?? 0) + cantidad);
  }

  observar(nombre, etiquetas = {}, valorSeg = 0) {
    const clave = this.#clave(nombre, etiquetas);
    let h = this.#histogramas.get(clave);
    if (!h) {
      h = { cubos: new Array(CUBOS.length).fill(0), suma: 0, total: 0 };
      this.#histogramas.set(clave, h);
    }
    // Los cubos de Prometheus son ACUMULATIVOS: el cubo le="0.1" cuenta
    // todas las observaciones <= 0.1, no solo las del intervalo.
    for (let i = 0; i < CUBOS.length; i++) {
      if (valorSeg <= CUBOS[i]) h.cubos[i]++;
    }
    h.suma += valorSeg;
    h.total++;
  }

  entrada() { this.#enVuelo++; }
  salida() { this.#enVuelo--; }

  /** Serializa todo el registro en el formato de exposicion de Prometheus. */
  exponer({ version = 'dev', entorno = 'desconocido' } = {}) {
    const lineas = [];

    lineas.push(
      '# HELP mini_reservalia_info Informacion de la instancia (valor siempre 1)',
      '# TYPE mini_reservalia_info gauge',
      `mini_reservalia_info{version="${version}",entorno="${entorno}"} 1`,
      '',
      '# HELP mini_reservalia_activo_segundos Segundos desde el arranque del proceso',
      '# TYPE mini_reservalia_activo_segundos gauge',
      `mini_reservalia_activo_segundos ${((Date.now() - this.#inicio) / 1000).toFixed(0)}`,
      '',
      '# HELP http_peticiones_en_vuelo Peticiones HTTP en curso ahora mismo',
      '# TYPE http_peticiones_en_vuelo gauge',
      `http_peticiones_en_vuelo ${this.#enVuelo}`,
      '',
    );

    // Contadores agrupados por nombre de metrica.
    const porNombre = new Map();
    for (const [clave, valor] of this.#contadores) {
      const [nombre, etiquetas] = clave.split('|');
      if (!porNombre.has(nombre)) porNombre.set(nombre, []);
      porNombre.get(nombre).push([etiquetas, valor]);
    }
    for (const [nombre, series] of porNombre) {
      lineas.push(`# HELP ${nombre} Contador acumulado`, `# TYPE ${nombre} counter`);
      for (const [etiquetas, valor] of series.sort()) {
        lineas.push(etiquetas ? `${nombre}{${etiquetas}} ${valor}` : `${nombre} ${valor}`);
      }
      lineas.push('');
    }

    // Histogramas: _bucket (acumulativos, con le="+Inf"), _sum y _count.
    const histPorNombre = new Map();
    for (const [clave, h] of this.#histogramas) {
      const [nombre, etiquetas] = clave.split('|');
      if (!histPorNombre.has(nombre)) histPorNombre.set(nombre, []);
      histPorNombre.get(nombre).push([etiquetas, h]);
    }
    for (const [nombre, series] of histPorNombre) {
      lineas.push(`# HELP ${nombre} Distribucion de duraciones en segundos`, `# TYPE ${nombre} histogram`);
      for (const [etiquetas, h] of series.sort()) {
        const sufijo = etiquetas ? `,${etiquetas}` : '';
        for (let i = 0; i < CUBOS.length; i++) {
          lineas.push(`${nombre}_bucket{le="${CUBOS[i]}"${sufijo}} ${h.cubos[i]}`);
        }
        lineas.push(`${nombre}_bucket{le="+Inf"${sufijo}} ${h.total}`);
        lineas.push(etiquetas ? `${nombre}_sum{${etiquetas}} ${h.suma.toFixed(6)}` : `${nombre}_sum ${h.suma.toFixed(6)}`);
        lineas.push(etiquetas ? `${nombre}_count{${etiquetas}} ${h.total}` : `${nombre}_count ${h.total}`);
      }
      lineas.push('');
    }

    return `${lineas.join('\n')}\n`;
  }

  reiniciar() {
    this.#contadores.clear();
    this.#histogramas.clear();
    this.#enVuelo = 0;
  }
}

export const metricas = new Metricas();

Y ahora el middleware. En src/servidor.js, envuelve el manejador:

// src/servidor.js  (anadidos de la 07-04)
import { metricas } from './metricas.js';

export const ENTORNO = process.env.ENTORNO ?? 'local';

/**
 * Normaliza la ruta para usarla como ETIQUETA.
 *
 * CRITICO: nunca uses la URL cruda como etiqueta. Cada valor distinto crea una
 * SERIE TEMPORAL nueva en Prometheus. Con `/api/huecos?fecha=...` tendrias una
 * serie por fecha consultada: miles de series, memoria disparada y consultas
 * imposibles. Es el error llamado "explosion de cardinalidad" y tumba
 * instalaciones de Prometheus enteras.
 */
function rutaNormalizada(pathname) {
  const conocidas = ['/salud', '/metricas', '/api/huecos', '/api/citas'];
  return conocidas.includes(pathname) ? pathname : '/otras';
}

function instrumentar(manejador) {
  return async (req, res) => {
    const comienzo = process.hrtime.bigint();
    metricas.entrada();

    // 'finish' se emite cuando la respuesta se ha enviado del todo,
    // que es el momento correcto para medir la latencia de extremo a extremo.
    res.once('finish', () => {
      const duracionSeg = Number(process.hrtime.bigint() - comienzo) / 1e9;
      const url = new URL(req.url, 'http://interno');
      const etiquetas = {
        metodo: req.method,
        ruta: rutaNormalizada(url.pathname),
        codigo: String(res.statusCode),
      };
      metricas.incrementar('http_peticiones_total', etiquetas);
      metricas.observar('http_duracion_segundos', {
        metodo: etiquetas.metodo,
        ruta: etiquetas.ruta,
      }, duracionSeg);
      if (res.statusCode >= 500) {
        metricas.incrementar('http_errores_total', { ruta: etiquetas.ruta, tipo: 'servidor' });
      } else if (res.statusCode >= 400) {
        metricas.incrementar('http_errores_total', { ruta: etiquetas.ruta, tipo: 'cliente' });
      }
      metricas.salida();
    });

    return manejador(req, res);
  };
}

  1. El endpoint /metricas en formato Prometheus

Envuelve el manejador al crear el servidor y añade la ruta de exposición. Prometheus no recibe nada: va él a buscarlo (modelo pull), haciendo un GET a /metricas cada pocos segundos. Esa inversión es lo que hace que la aplicación no necesite saber nada del sistema de monitorización: solo tiene que publicar un texto.

export function crearServidor({ repositorio, horario = HORARIO_POR_DEFECTO } = {}) {
  if (!repositorio) throw new Error('crearServidor requiere un repositorio');

  const manejador = async (req, res) => {
    const url = new URL(req.url, `http://${req.headers.host ?? 'localhost'}`);

    // --- Endpoint de metricas ---
    if (req.method === 'GET' && url.pathname === '/metricas') {
      const cuerpo = metricas.exponer({ version: VERSION, entorno: ENTORNO });
      res.writeHead(200, {
        // Este content-type exacto es el que espera Prometheus.
        'content-type': 'text/plain; version=0.0.4; charset=utf-8',
        'content-length': Buffer.byteLength(cuerpo),
      });
      return res.end(cuerpo);
    }

    // --- Retardo artificial para el ejercicio del apartado 9 ---
    // Controlado por variable de entorno: no se activa nunca por accidente.
    const retardo = Number(process.env.RETARDO_ARTIFICIAL_MS ?? 0);
    if (retardo > 0 && url.pathname === '/api/huecos') {
      await new Promise((r) => setTimeout(r, retardo));
    }

    /* ... el resto de rutas, sin cambios ... */
  };

  return http.createServer(instrumentar(manejador));
}

Añade también su prueba, que la cobertura sigue teniendo umbral:

// test/metricas.test.js
import test, { describe } from 'node:test';
import assert from 'node:assert/strict';
import { Metricas } from '../src/metricas.js';

describe('registro de metricas', () => {
  test('un contador se acumula por combinacion de etiquetas', () => {
    const m = new Metricas();
    m.incrementar('http_peticiones_total', { ruta: '/salud', codigo: '200' });
    m.incrementar('http_peticiones_total', { ruta: '/salud', codigo: '200' });
    m.incrementar('http_peticiones_total', { ruta: '/salud', codigo: '500' });
    const texto = m.exponer();
    assert.match(texto, /http_peticiones_total\{codigo="200",ruta="\/salud"\} 2/);
    assert.match(texto, /http_peticiones_total\{codigo="500",ruta="\/salud"\} 1/);
  });

  test('los cubos del histograma son ACUMULATIVOS', () => {
    const m = new Metricas();
    m.observar('http_duracion_segundos', { ruta: '/api/huecos' }, 0.03);
    const texto = m.exponer();
    // 0.03 s NO entra en le=0.025 pero SI en le=0.05 y en todos los mayores.
    assert.match(texto, /_bucket\{le="0.025",ruta="\/api\/huecos"\} 0/);
    assert.match(texto, /_bucket\{le="0.05",ruta="\/api\/huecos"\} 1/);
    assert.match(texto, /_bucket\{le="\+Inf",ruta="\/api\/huecos"\} 1/);
  });

  test('el histograma expone _sum y _count', () => {
    const m = new Metricas();
    m.observar('http_duracion_segundos', {}, 0.1);
    m.observar('http_duracion_segundos', {}, 0.3);
    const texto = m.exponer();
    assert.match(texto, /http_duracion_segundos_count 2/);
    assert.match(texto, /http_duracion_segundos_sum 0\.400000/);
  });

  test('las etiquetas se sanean para no romper el formato', () => {
    const m = new Metricas();
    m.incrementar('prueba_total', { ruta: 'con"comillas' });
    assert.doesNotMatch(m.exponer(), /ruta="con"comillas"/);
  });
});

Comprueba en local:

npm test                      # las nuevas pruebas en verde
BASE_DATOS='sqlite:/tmp/m.db' ENTORNO=local npm start &
for i in $(seq 1 20); do curl -s "localhost:3000/api/huecos?fecha=2026-03-02" > /dev/null; done
curl -s localhost:3000/api/huecos > /dev/null      # un 400
curl -s localhost:3000/metricas | head -30

Qué debes ver:

# HELP mini_reservalia_info Informacion de la instancia (valor siempre 1)
# TYPE mini_reservalia_info gauge
mini_reservalia_info{version="dev",entorno="local"} 1

# HELP mini_reservalia_activo_segundos Segundos desde el arranque del proceso
# TYPE mini_reservalia_activo_segundos gauge
mini_reservalia_activo_segundos 34

# HELP http_peticiones_en_vuelo Peticiones HTTP en curso ahora mismo
# TYPE http_peticiones_en_vuelo gauge
http_peticiones_en_vuelo 1

# HELP http_peticiones_total Contador acumulado
# TYPE http_peticiones_total counter
http_peticiones_total{codigo="200",metodo="GET",ruta="/api/huecos"} 20
http_peticiones_total{codigo="400",metodo="GET",ruta="/api/huecos"} 1

# HELP http_duracion_segundos Distribucion de duraciones en segundos
# TYPE http_duracion_segundos histogram
http_duracion_segundos_bucket{le="0.005",metodo="GET",ruta="/api/huecos"} 19
...

  1. Las cuatro señales de oro sobre estas métricas

La 03-06 presentaba las cuatro señales de oro del SRE. Aquí están, mapeadas a consultas concretas que puedes copiar y pegar:

Señal Pregunta que responde Consulta PromQL
Latencia ¿Cuánto tardan las peticiones que sí funcionan? histogram_quantile(0.95, sum by (le, ruta) (rate(http_duracion_segundos_bucket[5m])))
Tráfico ¿Cuánta demanda hay? sum by (ruta) (rate(http_peticiones_total[5m]))
Errores ¿Qué fracción falla? sum(rate(http_peticiones_total{codigo=~"5.."}[5m])) / sum(rate(http_peticiones_total[5m]))
Saturación ¿Cómo de lleno está el sistema? http_peticiones_en_vuelo

Dos matices que separan un panel útil de uno decorativo:

  • Latencia solo de las peticiones exitosas. Un 500 devuelto en 2 ms mejora tu percentil 95 y te hace creer que todo va rápido. Filtra: http_duracion_segundos_bucket{codigo=~"2.."} si añades el código como etiqueta del histograma (a costa de cardinalidad).
  • Errores 5xx, no 4xx. Un 400 porque el cliente mandó una fecha mal formada no es un fallo tuyo: es tu validación funcionando. Mezclarlos hace que tu tasa de error suba cuando alguien escanea tu API, y te acostumbra a ignorarla. Por eso http_errores_total separa tipo="cliente" de tipo="servidor".

  1. Prometheus y Grafana con docker compose

observabilidad/docker-compose.yml:

# observabilidad/docker-compose.yml
# Pila de observabilidad local: Prometheus (recoge y almacena) + Grafana (pinta).
services:
  prometheus:
    image: prom/prometheus:v2.53.0
    container_name: prometheus
    restart: unless-stopped
    ports: ['9090:9090']
    command:
      - '--config.file=/etc/prometheus/prometheus.yml'
      - '--storage.tsdb.retention.time=15d'
      # Necesario para recargar la configuracion sin reiniciar:
      #   curl -X POST http://localhost:9090/-/reload
      - '--web.enable-lifecycle'
    volumes:
      - ./prometheus.yml:/etc/prometheus/prometheus.yml:ro
      - ./alertas.yml:/etc/prometheus/alertas.yml:ro
      - datos-prometheus:/prometheus
    # Permite que Prometheus llegue a los contenedores de la app publicados
    # en el host (Linux; en Docker Desktop ya existe host.docker.internal).
    extra_hosts:
      - 'host.docker.internal:host-gateway'

  grafana:
    image: grafana/grafana:11.1.0
    container_name: grafana
    restart: unless-stopped
    ports: ['3000:3000']
    environment:
      GF_SECURITY_ADMIN_PASSWORD: admin
      GF_USERS_ALLOW_SIGN_UP: 'false'
      GF_AUTH_ANONYMOUS_ENABLED: 'true'
      GF_AUTH_ANONYMOUS_ORG_ROLE: Viewer
    volumes:
      # Aprovisionamiento como codigo: Grafana lee estos ficheros al arrancar.
      # NADA se configura pinchando en la interfaz: si no esta en el repo,
      # no existe (mismo principio que la infraestructura como codigo, 03-03).
      - ./grafana/provisioning:/etc/grafana/provisioning:ro
      - ./grafana/dashboards:/var/lib/grafana/dashboards:ro
      - datos-grafana:/var/lib/grafana
    depends_on: [prometheus]

volumes:
  datos-prometheus:
  datos-grafana:

observabilidad/prometheus.yml:

# observabilidad/prometheus.yml
global:
  scrape_interval: 15s       # cada cuanto se raspan los objetivos
  evaluation_interval: 15s   # cada cuanto se evaluan las reglas de alerta
  external_labels:
    proyecto: mini-reservalia

rule_files:
  - /etc/prometheus/alertas.yml

scrape_configs:
  # 1. El propio Prometheus (siempre util para saber si el se ha caido)
  - job_name: prometheus
    static_configs:
      - targets: ['localhost:9090']

  # 2. Mini-Reservalia, un objetivo por entorno.
  #    La etiqueta `entorno` permite comparar staging y produccion
  #    en el mismo panel, que es como se ve una regresion antes de que duela.
  - job_name: mini-reservalia
    metrics_path: /metricas
    scrape_interval: 10s
    scrape_timeout: 5s
    static_configs:
      - targets: ['host.docker.internal:3001']
        labels: { entorno: staging }
      - targets: ['host.docker.internal:3002']
        labels: { entorno: produccion }
    relabel_configs:
      # `instance` por defecto es "host:puerto", ilegible en los paneles.
      - source_labels: [entorno]
        target_label: instance

observabilidad/grafana/provisioning/datasources/prometheus.yml:

apiVersion: 1
datasources:
  - name: Prometheus
    type: prometheus
    access: proxy
    url: http://prometheus:9090
    isDefault: true
    uid: prometheus-mini

observabilidad/grafana/provisioning/dashboards/dashboards.yml:

apiVersion: 1
providers:
  - name: 'mini-reservalia'
    folder: 'Mini-Reservalia'
    type: file
    disableDeletion: false
    updateIntervalSeconds: 30
    allowUiUpdates: false      # los cambios se hacen en el repo, no en la UI
    options:
      path: /var/lib/grafana/dashboards

Levanta la pila:

docker compose -f observabilidad/docker-compose.yml up -d
docker compose -f observabilidad/docker-compose.yml ps

Qué debes ver. En http://localhost:9090/targets, la tabla de objetivos:

Endpoint State Labels
http://host.docker.internal:3001/metricas UP entorno="staging"
http://host.docker.internal:3002/metricas UP entorno="produccion"

Si alguno está DOWN con connection refused, el contenedor de ese entorno no está corriendo: despliégalo con ./scripts/desplegar.sh como en la 07-03.

Genera tráfico y prueba una consulta en http://localhost:9090/graph:

for i in $(seq 1 200); do
  curl -s "localhost:3002/api/huecos?fecha=2026-03-02&duracion=60" > /dev/null
  sleep 0.2
done
sum by (entorno) (rate(http_peticiones_total[1m]))

Qué debes ver: una línea con un valor cercano a 5 peticiones por segundo mientras dura el bucle.

  1. El panel como código

Un dashboard de Grafana es JSON. Que ese JSON esté en el repositorio y se aprovisione automáticamente significa que el panel se revisa en un PR, se versiona y se restaura solo si alguien lo rompe. Un panel construido a mano en la interfaz es conocimiento que existe en una base de datos que nadie hace copia de seguridad.

No vamos a pegar aquí los 500 renglones de un dashboard completo. Vamos a ver un panel entero, que es donde está la enseñanza, y a describir el resto.

observabilidad/grafana/dashboards/mini-reservalia.json (fragmento con un panel completo):

{
  "uid": "mini-reservalia",
  "title": "Mini-Reservalia · Señales de oro",
  "tags": ["mini-reservalia", "slo"],
  "timezone": "browser",
  "refresh": "10s",
  "time": { "from": "now-1h", "to": "now" },
  "templating": {
    "list": [
      {
        "name": "entorno",
        "type": "query",
        "datasource": { "type": "prometheus", "uid": "prometheus-mini" },
        "query": "label_values(http_peticiones_total, entorno)",
        "current": { "text": "produccion", "value": "produccion" },
        "includeAll": false
      }
    ]
  },
  "panels": [
    {
      "id": 1,
      "type": "timeseries",
      "title": "Latencia de /api/huecos (p50 · p95 · p99)",
      "description": "Percentiles calculados sobre los cubos del histograma. La linea roja es el objetivo del SLO (300 ms).",
      "gridPos": { "h": 9, "w": 12, "x": 0, "y": 0 },
      "datasource": { "type": "prometheus", "uid": "prometheus-mini" },
      "targets": [
        {
          "refId": "A",
          "expr": "histogram_quantile(0.50, sum by (le) (rate(http_duracion_segundos_bucket{ruta=\"/api/huecos\", entorno=\"$entorno\"}[5m])))",
          "legendFormat": "p50"
        },
        {
          "refId": "B",
          "expr": "histogram_quantile(0.95, sum by (le) (rate(http_duracion_segundos_bucket{ruta=\"/api/huecos\", entorno=\"$entorno\"}[5m])))",
          "legendFormat": "p95"
        },
        {
          "refId": "C",
          "expr": "histogram_quantile(0.99, sum by (le) (rate(http_duracion_segundos_bucket{ruta=\"/api/huecos\", entorno=\"$entorno\"}[5m])))",
          "legendFormat": "p99"
        }
      ],
      "fieldConfig": {
        "defaults": {
          "unit": "s",
          "min": 0,
          "custom": { "lineWidth": 2, "fillOpacity": 8, "showPoints": "never" },
          "thresholds": {
            "mode": "absolute",
            "steps": [
              { "color": "green", "value": null },
              { "color": "red", "value": 0.3 }
            ]
          }
        }
      },
      "options": {
        "legend": { "displayMode": "table", "placement": "bottom", "calcs": ["mean", "max"] },
        "tooltip": { "mode": "multi", "sort": "desc" }
      }
    }
  ],
  "annotations": {
    "list": [
      {
        "name": "Despliegues",
        "datasource": { "type": "prometheus", "uid": "prometheus-mini" },
        "enable": true,
        "iconColor": "rgba(0, 211, 255, 1)",
        "expr": "changes(mini_reservalia_activo_segundos{entorno=\"$entorno\"}[2m]) > 0",
        "titleFormat": "Despliegue",
        "textFormat": "Nueva version en {{entorno}}"
      }
    ]
  },
  "schemaVersion": 39,
  "version": 1
}

Léelo con atención, porque en ese único panel están todas las decisiones que importan:

Elemento Por qué está
histogram_quantile(0.95, ...) sobre rate(..._bucket[5m]) La única forma correcta de sacar percentiles de un histograma de Prometheus. rate antes de histogram_quantile, nunca al revés
sum by (le) Agrega las instancias conservando la etiqueta le. Si la pierdes, histogram_quantile devuelve NaN; es el error número uno
Los tres percentiles juntos p50 dice cómo va el caso típico; p99 dice cómo va el peor 1 %. La distancia entre ambos es la señal de un problema de colas
thresholds en 0.3 El objetivo del SLO dibujado en el panel. Un panel sin la línea del objetivo obliga a recordar el número
Variable $entorno El mismo panel sirve para staging y producción
annotations con changes(...activo_segundos...) Marca los despliegues: activo_segundos se reinicia al arrancar el proceso
unit: "s" Sin unidad, Grafana muestra 0.087 y hay que traducirlo mentalmente cada vez

Los demás paneles del dashboard, con su consulta (constrúyelos como ejercicio o cópialos del mismo patrón):

Panel Tipo Consulta
Tráfico por ruta timeseries sum by (ruta) (rate(http_peticiones_total{entorno="$entorno"}[5m]))
Tasa de error 5xx stat sum(rate(http_peticiones_total{codigo=~"5..",entorno="$entorno"}[5m])) / sum(rate(http_peticiones_total{entorno="$entorno"}[5m]))
Peticiones en vuelo timeseries http_peticiones_en_vuelo{entorno="$entorno"}
Cumplimiento del SLO (7 d) gauge sum(rate(http_duracion_segundos_bucket{le="0.25",ruta="/api/huecos",entorno="$entorno"}[7d])) / sum(rate(http_duracion_segundos_count{ruta="/api/huecos",entorno="$entorno"}[7d]))
Presupuesto de error restante gauge 1 - ((1 - <lo anterior>) / 0.005)
Versión desplegada stat mini_reservalia_info{entorno="$entorno"} con legend {{version}}

Abre http://localhost:3000 (admin/admin), ve a Dashboards → Mini-Reservalia. Qué debes ver: el dashboard ya existe sin haber importado nada. Modifícalo desde la interfaz: no te dejará guardar (allowUiUpdates: false). Eso es intencionado, y es la diferencia entre un panel que es código y uno que es un recuerdo.

  1. El SLO y su presupuesto de error, con la aritmética

Un SLO sin presupuesto de error calculado es un deseo. Vamos a hacer los números explícitos.

El SLO de Mini-Reservalia:

El 99,5 % de las peticiones a /api/huecos deben responder correctamente en menos de 300 ms, medido sobre una ventana móvil de 7 días.

Cuatro elementos, y los cuatro deben estar: el indicador (latencia de /api/huecos), el umbral (300 ms), el objetivo (99,5 %) y la ventana (7 días). Un SLO al que le falte cualquiera de ellos no se puede evaluar.

Por qué 99,5 % y no 99,99 %. Cada nueve adicional multiplica el coste por un factor cercano a diez: redundancia, guardias, complejidad. Mini-Reservalia es una herramienta de reservas para pequeños negocios; una petición lenta de cada doscientas es perfectamente tolerable y nadie cancela una suscripción por ello. Elegir el objetivo alcanzable más bajo que mantiene contentos a los usuarios es una decisión de ingeniería, no de pereza.

El presupuesto de error, con la aritmética completa:

Tráfico observado:      20 peticiones/minuto a /api/huecos
Ventana:                7 días

Peticiones en la ventana:
    20 pet/min × 60 min × 24 h × 7 d = 201.600 peticiones

Objetivo: 99,5 % correctas y rápidas
    Presupuesto de error = 100 % − 99,5 % = 0,5 %
    0,005 × 201.600 = 1.008 peticiones

  → Podemos permitirnos 1.008 peticiones lentas o fallidas en 7 días.

Traducido a tiempo, que es como se entiende de verdad:

Si TODAS las peticiones fallan durante una caída total:
    1.008 peticiones ÷ 20 pet/min = 50,4 minutos

  → El presupuesto entero equivale a unos 50 minutos de caída completa
    cada 7 días. O, repartido: unas 6 peticiones lentas cada hora.

Y ahora la parte que convierte el presupuesto en una herramienta de decisión:

Presupuesto consumido Qué significa Qué se hace
< 50 % Margen de sobra Se despliega con normalidad. Si el consumo es crónicamente bajo, el SLO es demasiado laxo: súbelo
50-75 % Atención Se sigue desplegando, pero las mejoras de fiabilidad suben de prioridad
75-100 % Alerta Solo cambios de bajo riesgo. Los que tocan la ruta afectada, en canary
> 100 % (agotado) Se ha incumplido Congelación de funcionalidad: el equipo trabaja en fiabilidad hasta recuperar margen

Ese es el valor real del presupuesto de error: convierte "¿desplegamos el viernes?" en una pregunta con respuesta numérica en vez de en una discusión de opiniones. Y funciona en las dos direcciones: con el 90 % del presupuesto intacto, la respuesta es "sí, adelante", y eso también hay que decirlo.

Regístralo en el repositorio, observabilidad/SLO.md:

# SLO de Mini-Reservalia

| Campo | Valor |
|---|---|
| Servicio | Mini-Reservalia · API |
| Indicador (SLI) | Proporción de peticiones a `/api/huecos` con código 2xx y latencia < 300 ms |
| Objetivo (SLO) | 99,5 % |
| Ventana | 7 días móviles |
| Presupuesto de error | 0,5 % ≈ 1.008 peticiones ≈ 50 min de caída total |
| Propietario | Equipo de plataforma |
| Revisión | Trimestral |

## Consulta del SLI

sum(rate(http_duracion_segundos_bucket{le="0.25", ruta="/api/huecos", codigo=~"2.."}[7d])) / sum(rate(http_duracion_segundos_count{ruta="/api/huecos"}[7d]))

> Nota: se usa el cubo `le="0.25"` porque es el límite de cubo más cercano por
> debajo de 300 ms. Los histogramas solo pueden responder sobre los límites que
> existen. Si el SLO fuera exactamente 300 ms, habría que **añadir un cubo de
> 0,3** a `src/metricas.js`. Esta es la contrapartida real de los histogramas:
> los cubos hay que elegirlos antes de saber qué vas a preguntar.

## Política del presupuesto

- < 50 % consumido: despliegue normal.
- 50-75 %: la fiabilidad sube de prioridad en el backlog.
- 75-100 %: solo cambios de bajo riesgo, en canary.
- \> 100 %: congelación de funcionalidad hasta recuperar margen.

Ese aviso sobre el cubo de 0,25 no es un detalle menor: es el tipo de cosa que se descubre tres meses después de definir el SLO, cuando ya hay datos históricos que no se pueden recalcular. Añade el cubo 0.3 a CUBOS en src/metricas.js ahora que estás a tiempo.

  1. Reglas de alerta por síntoma

La regla de oro de la 03-06: alerta sobre síntomas, no sobre causas. "La CPU está al 90 %" no es un problema si nadie lo nota; "el percentil 95 de latencia lleva cinco minutos por encima de 300 ms" sí lo es. Alertar sobre causas produce ruido; alertar sobre síntomas produce llamadas que valen la pena.

observabilidad/alertas.yml:

# observabilidad/alertas.yml
groups:
  - name: mini-reservalia-sintomas
    interval: 30s
    rules:
      # ---------------------------------------------------------------
      # SÍNTOMA 1: los usuarios esperan demasiado.
      # ---------------------------------------------------------------
      - alert: LatenciaAltaHuecos
        expr: |
          histogram_quantile(0.95,
            sum by (le, entorno) (
              rate(http_duracion_segundos_bucket{ruta="/api/huecos"}[5m])
            )
          ) > 0.3
        # `for` es lo que separa una alerta util de un generador de ruido:
        # la condicion debe mantenerse 5 minutos seguidos. Un pico de 20 s
        # durante un despliegue no despierta a nadie.
        for: 5m
        labels:
          severidad: aviso
          equipo: plataforma
          slo: latencia-huecos
        annotations:
          resumen: 'p95 de /api/huecos por encima de 300 ms en {{ $labels.entorno }}'
          descripcion: >-
            El percentil 95 lleva 5 minutos en {{ $value | humanizeDuration }},
            por encima del objetivo de 300 ms del SLO.
            Presupuesto de error en riesgo.
          runbook: 'https://github.com/OWNER/mini-reservalia/blob/main/observabilidad/RUNBOOK.md#latencia-alta'

      # ---------------------------------------------------------------
      # SÍNTOMA 2: el servicio devuelve errores propios.
      # ---------------------------------------------------------------
      - alert: TasaErroresAlta
        expr: |
          (
            sum by (entorno) (rate(http_peticiones_total{codigo=~"5.."}[5m]))
            /
            sum by (entorno) (rate(http_peticiones_total[5m]))
          ) > 0.01
        for: 3m
        labels:
          severidad: critica
          equipo: plataforma
        annotations:
          resumen: 'Mas del 1 % de errores 5xx en {{ $labels.entorno }}'
          descripcion: 'Tasa actual: {{ $value | humanizePercentage }}. Umbral: 1 %.'
          runbook: 'https://github.com/OWNER/mini-reservalia/blob/main/observabilidad/RUNBOOK.md#errores-5xx'

      # ---------------------------------------------------------------
      # SÍNTOMA 3: no hay servicio en absoluto.
      # ---------------------------------------------------------------
      - alert: ServicioCaido
        expr: up{job="mini-reservalia"} == 0
        for: 1m
        labels:
          severidad: critica
        annotations:
          resumen: 'Mini-Reservalia no responde en {{ $labels.entorno }}'
          descripcion: 'Prometheus no puede raspar /metricas desde hace 1 minuto.'

      # ---------------------------------------------------------------
      # SÍNTOMA 4 (predictivo): el presupuesto de error se agota rapido.
      # Esto es "burn rate": no alerta por estar mal, alerta por ir
      # camino de estarlo. Es lo que permite actuar antes del incumplimiento.
      # ---------------------------------------------------------------
      - alert: PresupuestoErrorConsumiendoseRapido
        expr: |
          (
            1 - (
              sum by (entorno) (rate(http_duracion_segundos_bucket{le="0.3", ruta="/api/huecos"}[1h]))
              /
              sum by (entorno) (rate(http_duracion_segundos_count{ruta="/api/huecos"}[1h]))
            )
          ) > (14.4 * 0.005)
        for: 2m
        labels:
          severidad: aviso
        annotations:
          resumen: 'Consumo acelerado del presupuesto de error en {{ $labels.entorno }}'
          descripcion: >-
            Al ritmo de la ultima hora, el presupuesto de 7 dias se agotaria
            en unas 12 horas (burn rate 14,4x). Umbral estandar de Google SRE.

El 14.4 de la última alerta merece explicación, porque parece un número mágico: es el factor que agota un presupuesto de 30 días en 2 días, y es el multiplicador estándar del SRE Workbook para la alerta de página rápida. Con nuestra ventana de 7 días, un burn rate de 14,4× agota el presupuesto en unas 12 horas. La idea es que te avise cuando aún puedes hacer algo, no cuando ya has incumplido.

Recarga Prometheus y comprueba:

docker compose -f observabilidad/docker-compose.yml restart prometheus
# o, sin reiniciar:
curl -X POST http://localhost:9090/-/reload

# Validar la sintaxis ANTES de recargar (hazlo siempre):
docker run --rm -v "$PWD/observabilidad:/o" --entrypoint promtool \
  prom/prometheus:v2.53.0 check rules /o/alertas.yml
# Checking /o/alertas.yml
#   SUCCESS: 4 rules found

Ese promtool check rules debería estar en tu ci.yml: las reglas de alerta son código y se validan como código. Una regla con un error de sintaxis hace que Prometheus ignore todo el fichero, y te enteras el día que necesitabas la alerta.

Qué debes ver en http://localhost:9090/alerts: las cuatro reglas en estado Inactive (verde).

  1. Disparar la alerta a propósito

Una alerta que nunca has visto dispararse es una hipótesis. Vamos a comprobarla.

# 1. Redesplegar staging con retardo artificial de 500 ms
docker rm -f mini-reservalia-staging
docker run -d --name mini-reservalia-staging \
  -p 3001:3000 \
  -e ENTORNO=staging \
  -e RETARDO_ARTIFICIAL_MS=500 \
  -e APP_VERSION=lenta \
  ghcr.io/TU_USUARIO/mini-reservalia@sha256:TU_DIGEST

# 2. Generar trafico sostenido durante 7 minutos
END=$((SECONDS+420))
while [ $SECONDS -lt $END ]; do
  curl -s "localhost:3001/api/huecos?fecha=2026-03-02" > /dev/null
  sleep 0.5
done

Qué debes ver, cronológicamente:

Momento http://localhost:9090/alerts Grafana
t = 0 LatenciaAltaHuecos Inactive p95 sube de golpe a ~0,5 s
t ≈ 40 s PENDING (amarillo) — la condición se cumple pero aún no lleva 5 min La línea cruza el umbral rojo de 0,3
t ≈ 5 min 40 s FIRING (rojo) La línea sigue por encima
Tras quitar el retardo, t + ~1 min Vuelve a Inactive La línea baja

Ese estado intermedio PENDING es el for: 5m. Vale la pena verlo: es la diferencia entre un sistema de alertas que la gente atiende y uno que la gente silencia. Sin for, un pico de tres segundos durante un despliegue rutinario habría disparado la alerta.

Prueba también la alerta de errores:

docker rm -f mini-reservalia-staging   # ServicioCaido pasa a FIRING en 1 min

Y restaura la versión buena:

IMAGEN=ghcr.io/TU_USUARIO/mini-reservalia@sha256:BUENO \
ENTORNO=staging PUERTO=3001 ./scripts/desplegar.sh

Escribe además el runbook al que apuntan las anotaciones, observabilidad/RUNBOOK.md, porque una alerta sin runbook obliga a improvisar a las 3 de la mañana:

# Runbook de Mini-Reservalia

## LatenciaAltaHuecos

**Síntoma:** p95 de `/api/huecos` > 300 ms durante 5 minutos.
**Impacto:** los usuarios ven la lista de huecos con retraso perceptible. Consume presupuesto de error.

**Diagnóstico, en orden:**
1. ¿Ha habido un despliegue reciente? Mira las anotaciones del panel.
   `gh run list --workflow=cd.yml --limit 5`
2. ¿Ha subido el tráfico? Panel "Tráfico por ruta". Si sí, es capacidad, no regresión.
3. ¿Están las peticiones en vuelo altas? Panel "Saturación". Si sí, hay encolamiento.
4. `docker logs --tail 200 mini-reservalia-produccion`

**Mitigación:**
- Si coincide con un despliegue: **rollback primero, investigar después**.
  `gh workflow run rollback.yml -f entorno=produccion -f digest=<anterior> -f motivo="LatenciaAltaHuecos"`
- Si es carga: escalar (más réplicas / más recursos).

**Escalado:** si en 30 minutos no se ha mitigado, avisar al responsable del servicio.

## Errores 5xx

**Síntoma:** más del 1 % de respuestas 5xx durante 3 minutos.
**Primera acción:** `docker logs --tail 200` y buscar `Error no controlado`.
**Causa más frecuente:** la base de datos no accesible (volumen sin permisos tras un despliegue).

  1. Marcar los despliegues en el panel

La pregunta más frecuente en un incidente es: "¿hemos tocado algo?". Un panel que superpone los despliegues sobre las métricas la responde de un vistazo.

Ya tenemos una marca implícita —changes(mini_reservalia_activo_segundos[2m]), porque el contador se reinicia al arrancar el proceso—, pero es indirecta y no lleva metadatos. Vamos a enviar una anotación explícita desde el cd.yml.

Añade al final del job produccion:

      - name: Anotar el despliegue en Grafana
        if: always() && vars.GRAFANA_URL != ''
        continue-on-error: true    # una anotacion fallida NO debe tumbar el despliegue
        env:
          GRAFANA_URL: ${{ vars.GRAFANA_URL }}
          GRAFANA_TOKEN: ${{ secrets.GRAFANA_TOKEN }}
        run: |
          set -Eeuo pipefail
          AHORA_MS=$(( $(date +%s) * 1000 ))
          ESTADO="${{ job.status }}"
          COLOR=$([ "$ESTADO" = "success" ] && echo "verde" || echo "rojo")

          curl -sS -X POST "${GRAFANA_URL}/api/annotations" \
            -H "Authorization: Bearer ${GRAFANA_TOKEN}" \
            -H 'Content-Type: application/json' \
            -d @- <<JSON
          {
            "dashboardUID": "mini-reservalia",
            "time": ${AHORA_MS},
            "timeEnd": ${AHORA_MS},
            "tags": ["despliegue", "produccion", "${ESTADO}", "${COLOR}"],
            "text": "<b>Despliegue ${ESTADO}</b><br/>Commit: ${{ needs.preparar.outputs.commit }}<br/>Digest: <code>${{ needs.preparar.outputs.digest }}</code><br/>Por: @${{ github.actor }}<br/><a href='${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}'>Ver el run</a>"
          }
          JSON
          echo "Anotacion enviada a Grafana."

Configúralo:

# En Grafana: Administration > Service accounts > Add > rol Editor > Add token
gh variable set GRAFANA_URL --body "http://localhost:3000"
gh secret set GRAFANA_TOKEN --body "glsa_xxxxx"

Añade la capa de anotaciones al dashboard:

{
  "name": "Despliegues (pipeline)",
  "datasource": { "type": "grafana", "uid": "-- Grafana --" },
  "enable": true,
  "iconColor": "rgba(0, 211, 255, 1)",
  "target": { "type": "tags", "matchAny": false, "tags": ["despliegue", "produccion"] }
}

Qué debes ver: una línea vertical con un triángulo en la base del gráfico en el momento exacto de cada despliegue; al pasar el ratón, el commit, el digest, quién aprobó y un enlace al run.

Y ahora lo que enseña de verdad. Redespliega la versión con RETARDO_ARTIFICIAL_MS=500 y mira el panel: verás la línea vertical del despliegue y, exactamente a partir de ella, la curva de p95 subiendo. Esa imagen —el momento del cambio y el momento del empeoramiento coincidiendo— es la que convierte una discusión de media hora ("no creo que sea nuestro, será la red") en una decisión de treinta segundos. Es probablemente la mejor relación valor/esfuerzo de toda esta lección: veinte líneas de curl en el cd.yml.

Equivalente real en Reservalia. El cd.yml de Reservalia envía la anotación a Grafana Cloud y, además, un evento de despliegue a la herramienta de APM, de modo que las trazas quedan etiquetadas con la versión. Con eso, "¿desde cuándo va lento?" se responde filtrando por versión en lugar de por hora.

  1. Cerrar el bucle: las cuatro DORA de tu repositorio

Hasta aquí has medido el sistema. Ahora vamos a medir el proceso, que es lo que la 01-05 introdujo con la línea base de Reservalia. Las cuatro métricas y cómo se calculan desde la API de GitHub:

Métrica DORA Definición Fuente en GitHub
Frecuencia de despliegue Despliegues a producción por unidad de tiempo Deployments con environment=produccion y estado success
Lead time for changes Del commit a producción commit.author.datedeployment_status.created_at
Change failure rate % de despliegues que requieren remedio Despliegues fallidos + ejecuciones de rollback.yml ÷ total
Time to restore Del fallo a la recuperación Del despliegue fallido al siguiente despliegue con éxito

scripts/dora.js:

#!/usr/bin/env node
// scripts/dora.js
// Calcula las cuatro metricas DORA de ESTE repositorio con la API de GitHub.
//
// Uso:
//   GH_TOKEN=... REPO=owner/repo DIAS=30 node scripts/dora.js
//
// Se apoya en los Deployments que GitHub crea automaticamente cuando un job
// usa `environment:`. Por eso el `cd.yml` de la 07-03 los genera sin escribir
// una linea extra: usar Environments te regala la trazabilidad.

const TOKEN = process.env.GH_TOKEN ?? process.env.GITHUB_TOKEN;
const REPO = process.env.REPO ?? process.env.GITHUB_REPOSITORY;
const DIAS = Number(process.env.DIAS ?? 30);
const ENTORNO = process.env.ENTORNO_PRODUCCION ?? 'produccion';

if (!TOKEN || !REPO) {
  console.error('Faltan GH_TOKEN y/o REPO (owner/repo)');
  process.exit(2);
}

const DESDE = new Date(Date.now() - DIAS * 24 * 3600 * 1000);

async function api(ruta) {
  const respuesta = await fetch(`https://api.github.com${ruta}`, {
    headers: {
      authorization: `Bearer ${TOKEN}`,
      accept: 'application/vnd.github+json',
      'x-github-api-version': '2022-11-28',
    },
  });
  if (!respuesta.ok) {
    throw new Error(`GitHub API ${respuesta.status} en ${ruta}: ${await respuesta.text()}`);
  }
  return respuesta.json();
}

// ---------------------------------------------------------------------------
// 1. Recopilar los despliegues del entorno de produccion
// ---------------------------------------------------------------------------
const despliegues = [];
for (let pagina = 1; pagina <= 5; pagina++) {
  const lote = await api(`/repos/${REPO}/deployments?environment=${ENTORNO}&per_page=100&page=${pagina}`);
  if (lote.length === 0) break;
  for (const d of lote) {
    if (new Date(d.created_at) < DESDE) continue;
    const estados = await api(`/repos/${REPO}/deployments/${d.id}/statuses?per_page=100`);
    const finales = estados.filter((e) => ['success', 'failure', 'error'].includes(e.state));
    if (finales.length === 0) continue;
    const final = finales[0]; // la API los devuelve mas reciente primero
    despliegues.push({
      id: d.id,
      sha: d.sha,
      creado: new Date(d.created_at),
      terminado: new Date(final.created_at),
      exito: final.state === 'success',
    });
  }
  if (lote.length < 100) break;
}
despliegues.sort((a, b) => a.terminado - b.terminado);

if (despliegues.length === 0) {
  console.log(`Sin despliegues a "${ENTORNO}" en los ultimos ${DIAS} dias.`);
  process.exit(0);
}

// ---------------------------------------------------------------------------
// 2. Métrica 1: frecuencia de despliegue
// ---------------------------------------------------------------------------
const exitosos = despliegues.filter((d) => d.exito);
const porSemana = (exitosos.length / DIAS) * 7;

// ---------------------------------------------------------------------------
// 3. Métrica 2: lead time (commit -> produccion)
// ---------------------------------------------------------------------------
const leadTimes = [];
for (const d of exitosos) {
  try {
    const commit = await api(`/repos/${REPO}/commits/${d.sha}`);
    const fechaCommit = new Date(commit.commit.author.date);
    const horas = (d.terminado - fechaCommit) / 3_600_000;
    if (horas >= 0 && horas < 24 * 90) leadTimes.push(horas);
  } catch {
    /* commit borrado por un force-push o similar: se ignora */
  }
}
const mediana = (xs) => {
  if (xs.length === 0) return 0;
  const s = [...xs].sort((a, b) => a - b);
  const m = Math.floor(s.length / 2);
  return s.length % 2 ? s[m] : (s[m - 1] + s[m]) / 2;
};
const leadMediana = mediana(leadTimes);

// ---------------------------------------------------------------------------
// 4. Métrica 3: change failure rate
//    Un despliegue "falla" si su estado es failure/error O si fue seguido
//    de un rollback. Lo segundo es lo que la mayoria olvida contar, y es
//    justo el caso mas grave: el despliegue "funciono" pero rompio algo.
// ---------------------------------------------------------------------------
const runs = await api(
  `/repos/${REPO}/actions/workflows/rollback.yml/runs?per_page=100&created=%3E${DESDE.toISOString().slice(0, 10)}`,
).catch(() => ({ workflow_runs: [] }));
const rollbacks = (runs.workflow_runs ?? []).filter((r) => r.conclusion === 'success');

const fallidos = despliegues.filter((d) => !d.exito).length;
const cfr = ((fallidos + rollbacks.length) / despliegues.length) * 100;

// ---------------------------------------------------------------------------
// 5. Métrica 4: time to restore
//    Del primer despliegue fallido al siguiente con exito.
// ---------------------------------------------------------------------------
const restauraciones = [];
for (let i = 0; i < despliegues.length; i++) {
  if (despliegues[i].exito) continue;
  const siguiente = despliegues.slice(i + 1).find((d) => d.exito);
  if (siguiente) restauraciones.push((siguiente.terminado - despliegues[i].terminado) / 60_000);
}
for (const r of rollbacks) {
  const minutos = (new Date(r.updated_at) - new Date(r.created_at)) / 60_000;
  if (minutos > 0 && minutos < 24 * 60) restauraciones.push(minutos);
}
const restauracionMediana = mediana(restauraciones);

// ---------------------------------------------------------------------------
// 6. Clasificación (umbrales del informe State of DevOps)
// ---------------------------------------------------------------------------
const nivelFrecuencia = porSemana >= 7 ? 'Elite' : porSemana >= 1 ? 'Alto' : porSemana >= 0.25 ? 'Medio' : 'Bajo';
const nivelLead = leadMediana < 24 ? 'Elite' : leadMediana < 168 ? 'Alto' : leadMediana < 720 ? 'Medio' : 'Bajo';
const nivelCfr = cfr <= 5 ? 'Elite' : cfr <= 10 ? 'Alto' : cfr <= 15 ? 'Medio' : 'Bajo';
const nivelRestore = restauracionMediana < 60 ? 'Elite' : restauracionMediana < 1440 ? 'Alto' : 'Medio';

const fmtHoras = (h) => (h < 1 ? `${(h * 60).toFixed(0)} min` : h < 48 ? `${h.toFixed(1)} h` : `${(h / 24).toFixed(1)} d`);

const informe = `## 📊 Métricas DORA · últimos ${DIAS} días

| Métrica | Valor | Nivel |
|---|---|---|
| **Frecuencia de despliegue** | ${porSemana.toFixed(1)} / semana | ${nivelFrecuencia} |
| **Lead time (mediana)** | ${fmtHoras(leadMediana)} | ${nivelLead} |
| **Change failure rate** | ${cfr.toFixed(1)} % | ${nivelCfr} |
| **Time to restore (mediana)** | ${restauracionMediana.toFixed(0)} min | ${nivelRestore} |

<details><summary>Detalle del cálculo</summary>

- Despliegues a \`${ENTORNO}\` analizados: **${despliegues.length}** (${exitosos.length} con éxito, ${fallidos} fallidos)
- Ejecuciones de rollback con éxito: **${rollbacks.length}**
- Muestras de lead time: ${leadTimes.length}
- Muestras de restauración: ${restauraciones.length}
- Ventana: desde ${DESDE.toISOString().slice(0, 10)}

</details>

> Las cuatro se calculan desde la API de GitHub. La frecuencia y el lead time
> miden **rapidez**; el CFR y el time to restore miden **estabilidad**. Mejorar
> las primeras empeorando las segundas no es mejorar: las cuatro se leen juntas.
`;

console.log(informe);
if (process.env.GITHUB_STEP_SUMMARY) {
  const { appendFile } = await import('node:fs/promises');
  await appendFile(process.env.GITHUB_STEP_SUMMARY, informe);
}

El workflow programado, .github/workflows/dora.yml:

name: Metricas DORA

on:
  schedule:
    # Lunes a las 08:00 UTC. Ojo: `schedule` usa SIEMPRE UTC y GitHub
    # puede retrasarlo varios minutos si hay cola. No lo uses para nada
    # que dependa de la puntualidad.
    - cron: '0 8 * * 1'
  workflow_dispatch:
    inputs:
      dias:
        description: 'Ventana de analisis en dias'
        default: '30'
        type: string

permissions:
  contents: read
  deployments: read
  actions: read

jobs:
  calcular:
    name: Calcular DORA
    runs-on: ubuntu-latest
    timeout-minutes: 10
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with: { node-version: '20' }

      - name: Calcular las cuatro metricas
        env:
          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
          REPO: ${{ github.repository }}
          DIAS: ${{ inputs.dias || '30' }}
        run: node scripts/dora.js | tee informe-dora.md

      - name: Guardar el informe historico
        uses: actions/upload-artifact@v4
        with:
          name: dora-${{ github.run_id }}
          path: informe-dora.md
          retention-days: 90

      # Opcional pero muy recomendable: abrir/actualizar un issue fijado
      # para que las metricas se vean sin buscarlas.
      - name: Publicar en un issue
        env:
          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
        run: |
          NUM=$(gh issue list --label dora --state open --limit 1 --json number --jq '.[0].number // empty')
          if [ -n "$NUM" ]; then
            gh issue comment "$NUM" --body-file informe-dora.md
          else
            gh issue create --title "Métricas DORA (informe semanal)" \
              --label dora --body-file informe-dora.md
          fi

Ejecútalo a mano:

gh workflow run dora.yml -f dias=30
gh run watch

Qué debes ver en el resumen del run:

## 📊 Métricas DORA · últimos 30 días

| Métrica | Valor | Nivel |
|---|---|---|
| Frecuencia de despliegue | 3.5 / semana | Alto |
| Lead time (mediana) | 42 min | Elite |
| Change failure rate | 12.5 % | Medio |
| Time to restore (mediana) | 2 min | Elite |

Un CFR alto en este laboratorio es normal y esperable: has provocado fallos a propósito. Y ahí está la lección: un número aislado no significa nada; lo que significa algo es la tendencia. Reservalia pasó de 1,5 despliegues/semana, 68 h de lead time, 6,5 % de CFR y 68 minutos de restauración a 12/semana, 3,5 h, 3,8 % y 9 minutos. No porque alguien decidiera "mejorar las DORA", sino porque cada mejora concreta del pipeline —caché, paralelización, artefacto inmutable, rollback por digest, puertas automáticas— movió alguna de las cuatro. Las métricas son el termómetro, no la medicina.

  1. Rollback automático por métricas

El cierre del bucle. El smoke test valida treinta segundos; ahora vamos a vigilar diez minutos y a revertir solos si algo se degrada.

scripts/vigilar-despliegue.sh:

#!/usr/bin/env bash
# Vigila las metricas del servicio DESPUES de desplegar.
# Sale 0 si todo va bien; sale 1 si hay que revertir.
#
# Uso:
#   BASE=http://localhost:3002 VENTANA_MIN=10 ./scripts/vigilar-despliegue.sh

set -Eeuo pipefail

BASE="${BASE:?Falta BASE}"
VENTANA_MIN="${VENTANA_MIN:-10}"
UMBRAL_ERROR_PCT="${UMBRAL_ERROR_PCT:-2.0}"
UMBRAL_P95_SEG="${UMBRAL_P95_SEG:-0.5}"
INTERVALO_SEG="${INTERVALO_SEG:-30}"
# Cuantas comprobaciones consecutivas malas hacen falta para revertir.
# Con 1 sola, un pico transitorio provocaria un rollback innecesario:
# el rollback tambien es un cambio, y los cambios innecesarios tienen coste.
MALAS_SEGUIDAS_MAX="${MALAS_SEGUIDAS_MAX:-3}"

log() { printf '[vigilancia %s] %s\n' "$(date -u +%H:%M:%S)" "$*"; }

# Lee una metrica del endpoint /metricas por nombre exacto de serie.
leer_metrica() {
  local patron="$1"
  curl -fsS --max-time 5 "$BASE/metricas" 2>/dev/null \
    | grep -E "^${patron}" | awk '{s+=$NF} END {print (NR?s:0)}'
}

log "Vigilando $BASE durante $VENTANA_MIN min"
log "Umbrales: errores 5xx < ${UMBRAL_ERROR_PCT}% · p95 < ${UMBRAL_P95_SEG}s"

# Linea base: los contadores son acumulativos desde el arranque, asi que
# medimos INCREMENTOS respecto al inicio de la vigilancia.
BASE_TOTAL=$(leer_metrica 'http_peticiones_total\{')
BASE_5XX=$(leer_metrica 'http_peticiones_total\{codigo="5')
BASE_SUM=$(leer_metrica 'http_duracion_segundos_sum')
BASE_CNT=$(leer_metrica 'http_duracion_segundos_count')
log "Linea base: total=$BASE_TOTAL 5xx=$BASE_5XX"

FIN=$(( $(date +%s) + VENTANA_MIN * 60 ))
MALAS=0
CICLO=0

while [ "$(date +%s)" -lt "$FIN" ]; do
  sleep "$INTERVALO_SEG"
  CICLO=$((CICLO + 1))

  if ! curl -fsS --max-time 5 "$BASE/salud" >/dev/null 2>&1; then
    log "CRITICO: /salud no responde. Revertir inmediatamente."
    exit 1
  fi

  TOTAL=$(leer_metrica 'http_peticiones_total\{')
  CINCOXX=$(leer_metrica 'http_peticiones_total\{codigo="5')
  SUM=$(leer_metrica 'http_duracion_segundos_sum')
  CNT=$(leer_metrica 'http_duracion_segundos_count')

  D_TOTAL=$(awk "BEGIN{print $TOTAL - $BASE_TOTAL}")
  D_5XX=$(awk "BEGIN{print $CINCOXX - $BASE_5XX}")
  D_SUM=$(awk "BEGIN{print $SUM - $BASE_SUM}")
  D_CNT=$(awk "BEGIN{print $CNT - $BASE_CNT}")

  # Sin trafico no se puede concluir nada. No revertir por falta de datos:
  # "no sé" no es lo mismo que "va mal".
  if awk "BEGIN{exit !($D_TOTAL < 5)}"; then
    log "Ciclo $CICLO: solo $D_TOTAL peticiones; muestra insuficiente, se omite."
    continue
  fi

  PCT_ERROR=$(awk "BEGIN{printf \"%.2f\", ($D_5XX * 100) / $D_TOTAL}")
  LAT_MEDIA=$(awk "BEGIN{printf \"%.3f\", ($D_CNT > 0) ? $D_SUM / $D_CNT : 0}")

  log "Ciclo $CICLO: peticiones=$D_TOTAL errores5xx=${PCT_ERROR}% latencia_media=${LAT_MEDIA}s"

  MALA=0
  awk "BEGIN{exit !($PCT_ERROR > $UMBRAL_ERROR_PCT)}" && { log "  ⚠ errores por encima del umbral"; MALA=1; }
  awk "BEGIN{exit !($LAT_MEDIA > $UMBRAL_P95_SEG)}"  && { log "  ⚠ latencia por encima del umbral"; MALA=1; }

  if [ "$MALA" -eq 1 ]; then
    MALAS=$((MALAS + 1))
    log "  Comprobaciones malas consecutivas: $MALAS/$MALAS_SEGUIDAS_MAX"
    if [ "$MALAS" -ge "$MALAS_SEGUIDAS_MAX" ]; then
      log "DECISION: revertir. $MALAS comprobaciones consecutivas fuera de umbral."
      exit 1
    fi
  else
    [ "$MALAS" -gt 0 ] && log "  Recuperado; contador de malas a cero."
    MALAS=0
  fi
done

log "Vigilancia completada sin incidencias. Despliegue estable."
exit 0

Y el job en el cd.yml, después del despliegue a producción:

  vigilar:
    name: Vigilancia posterior al despliegue
    runs-on: ubuntu-latest
    needs: [preparar, produccion]
    timeout-minutes: 20
    permissions:
      contents: read
      actions: write        # necesario para lanzar el workflow de rollback
    steps:
      - uses: actions/checkout@v4

      - name: Generar trafico sintetico de fondo
        run: |
          # Sin trafico no hay metricas. En un sistema real esto sobra:
          # el trafico lo generan los usuarios.
          (for i in $(seq 1 600); do
             curl -s "http://localhost:3002/api/huecos?fecha=2026-03-02" > /dev/null 2>&1 || true
             sleep 1
           done) &
          echo "generador=$!" >> "$GITHUB_ENV"

      - name: Vigilar 10 minutos
        id: vigilancia
        continue-on-error: true    # queremos DECIDIR segun el resultado, no abortar
        env:
          BASE: http://localhost:3002
          VENTANA_MIN: '10'
          UMBRAL_ERROR_PCT: '2.0'
          UMBRAL_P95_SEG: '0.5'
        run: ./scripts/vigilar-despliegue.sh

      - name: Rollback automatico si la vigilancia ha fallado
        if: steps.vigilancia.outcome == 'failure'
        env:
          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
        run: |
          set -Eeuo pipefail
          ANTERIOR="${{ needs.produccion.outputs.digest_anterior }}"
          if [ -z "$ANTERIOR" ]; then
            echo "::error::La vigilancia fallo pero no hay digest anterior conocido. INTERVENCION MANUAL."
            exit 1
          fi

          echo "::warning::Metricas degradadas tras el despliegue. Revirtiendo a $ANTERIOR"
          gh workflow run rollback.yml \
            -f entorno=produccion \
            -f digest="$ANTERIOR" \
            -f motivo="Rollback AUTOMATICO: metricas fuera de umbral tras desplegar ${{ needs.preparar.outputs.digest }}"

          {
            echo "## 🔴 Rollback automatico disparado"
            echo ""
            echo "| Campo | Valor |"
            echo "|---|---|"
            echo "| Digest problematico | \`${{ needs.preparar.outputs.digest }}\` |"
            echo "| Revertido a | \`$ANTERIOR\` |"
            echo "| Motivo | Umbrales de error o latencia superados durante 3 ciclos |"
            echo ""
            echo "**Acción requerida:** escribir el post-mortem antes de reintentar."
          } >> "$GITHUB_STEP_SUMMARY"
          exit 1

      - name: Confirmar despliegue estable
        if: steps.vigilancia.outcome == 'success'
        run: echo "### ✅ Despliegue estable tras 10 minutos de vigilancia" >> "$GITHUB_STEP_SUMMARY"

Para que needs.produccion.outputs.digest_anterior exista, añade el output al job produccion:

    outputs:
      digest_anterior: ${{ steps.desplegar.outputs.digest_anterior }}

(El script desplegar.sh de la 07-03 ya lo escribe en $GITHUB_OUTPUT.)

Pruébalo de verdad: despliega la versión con RETARDO_ARTIFICIAL_MS=800, que supera el umbral de 0,5 s.

Qué debes ver:

[vigilancia 10:15:00] Vigilando http://localhost:3002 durante 10 min
[vigilancia 10:15:30] Ciclo 1: peticiones=29 errores5xx=0.00% latencia_media=0.812s
[vigilancia 10:15:30]   ⚠ latencia por encima del umbral
[vigilancia 10:15:30]   Comprobaciones malas consecutivas: 1/3
[vigilancia 10:16:00] Ciclo 2: ... latencia_media=0.809s
[vigilancia 10:16:00]   Comprobaciones malas consecutivas: 2/3
[vigilancia 10:16:30] Ciclo 3: ... latencia_media=0.815s
[vigilancia 10:16:30] DECISION: revertir. 3 comprobaciones consecutivas fuera de umbral.

Y a continuación, el rollback.yml arrancando solo. Tiempo total desde el despliegue malo hasta el servicio restaurado: unos 3 minutos, sin que ninguna persona hiciera nada. Eso es lo que mueve el "time to restore" de 68 minutos a 9 en la línea de Reservalia.

Las tres decisiones de diseño que hacen que esto sea seguro y no un generador de caos:

Decisión Por qué
3 ciclos malos consecutivos, no uno Un pico transitorio no debe provocar un rollback. El rollback también es un cambio
Muestra mínima (5 peticiones) Sin tráfico, "0 errores de 0 peticiones" no es información. No revertir por falta de datos
Se aborta si no hay digest anterior Un rollback a ninguna parte es peor que el problema. Mejor escalar a una persona

  1. Verificación final

# Comprobación Cómo Esperado
1 /metricas responde en formato Prometheus curl localhost:3002/metricas Líneas # HELP, # TYPE, series
2 Los cubos son acumulativos npm test La prueba del histograma en verde
3 No hay explosión de cardinalidad curl -s .../metricas | grep -c '^http_peticiones_total{' Menos de 20 series
4 Prometheus raspa los dos entornos localhost:9090/targets Dos objetivos UP
5 El dashboard se aprovisiona solo Grafana sin importar nada Panel presente
6 El panel no se puede editar en la UI Intentar guardar Bloqueado
7 El SLO está documentado con su aritmética observabilidad/SLO.md Los cuatro elementos + presupuesto
8 Las reglas son válidas promtool check rules SUCCESS: 4 rules found
9 La alerta pasa por PENDING y llega a FIRING Retardo artificial + 6 min Los tres estados observados
10 La alerta se recupera sola Quitar el retardo Vuelve a Inactive
11 Los despliegues se anotan Panel tras un despliegue Línea vertical con metadatos
12 Las DORA se calculan gh workflow run dora.yml Tabla con las cuatro
13 El rollback automático se dispara Desplegar con retardo de 800 ms Rollback lanzado solo en ~3 min
14 No revierte por falta de datos Vigilar sin tráfico "muestra insuficiente, se omite"

Errores Comunes y Consejos

Síntoma: histogram_quantile devuelve NaN o no dibuja nada. Causa: has perdido la etiqueta le en la agregación. sum(rate(...bucket[5m])) sin by (le) destruye la información del histograma. Arreglo: siempre sum by (le, <las demás>) (rate(..._bucket[5m])). Es el error número uno de PromQL.

Síntoma: Prometheus muestra el objetivo DOWN con connection refused. Causa: desde dentro del contenedor de Prometheus, localhost es el propio Prometheus, no tu máquina. Arreglo: host.docker.internal con el extra_hosts: host-gateway que ya está en el compose. Verifica: docker exec prometheus wget -qO- http://host.docker.internal:3002/metricas | head -3.

Síntoma: la memoria de Prometheus crece sin control y las consultas tardan segundos. Causa: explosión de cardinalidad. Alguna etiqueta tiene valores ilimitados: una URL completa, un id de usuario, una marca de tiempo. Arreglo: la función rutaNormalizada del apartado 2. Regla: el número de valores distintos de una etiqueta debe ser acotado y pequeño. Diagnóstico: topk(10, count by (__name__)({__name__=~".+"})).

Síntoma: la alerta se dispara y se apaga sola cada pocos minutos ("flapping"). Causa: el for es demasiado corto para la volatilidad de la métrica, o el umbral está justo en el valor habitual. Arreglo: sube el for o aleja el umbral. Regla práctica: el umbral debe estar al menos un 50 % por encima del percentil 99 de operación normal. Una alerta que se dispara a diario deja de leerse en una semana.

Síntoma: los contadores se reinician a cero de repente y el rate() da un pico raro. Causa: el proceso se reinició (un despliegue). Las métricas viven en memoria. Arreglo: ninguno necesario. rate() de Prometheus detecta los reinicios de contador y los compensa. Por eso rate() sobre un counter es correcto y restar valores a mano no lo es.

Síntoma: el job schedule de DORA no se ejecuta a su hora, o deja de ejecutarse. Causas: (1) cron es UTC, siempre; (2) GitHub retrasa los schedule cuando hay carga, hasta bastantes minutos; (3) GitHub desactiva los workflows programados en repositorios sin actividad durante 60 días, y te lo avisa por correo una vez. Arreglo: no dependas de la puntualidad; mantén el workflow_dispatch para poder lanzarlo a mano.

Síntoma: el rollback automático se dispara en un despliegue perfectamente bueno. Causa: el umbral se evaluó durante el arranque, con las cachés frías y las primeras peticiones lentas. Arreglo: añade un periodo de gracia antes de empezar a contar (sleep 60 inicial), o descarta el primer ciclo. Es el equivalente al start-period del HEALTHCHECK de Docker.

Consejo — instrumenta el flujo de negocio, no solo el HTTP. Las señales de oro te dicen si el sistema funciona. Un contador citas_creadas_total te dice si el producto funciona. Un despliegue puede dejar todos los HTTP en 200 y las citas creadas en cero, y esa es la caída que de verdad cuesta dinero. Es el ejercicio 2.

Consejo — la métrica que casi nadie pone y siempre hace falta. mini_reservalia_info{version="..."} no mide nada, pero responde instantáneamente a "¿qué versión hay desplegada?" desde el mismo sitio donde ves el problema. Cuesta tres líneas.

Ejercicios

Ejercicio 1: un SLO de disponibilidad, además del de latencia

Define un segundo SLO —99,9 % de peticiones a /api/huecos sin error 5xx en 30 días—, calcula su presupuesto de error con la aritmética explícita, escribe la consulta del SLI y añade una alerta de burn rate con dos ventanas (una rápida y una lenta) para evitar falsos positivos.

Ejercicio 2: métricas de negocio

Añade métricas que midan el producto y no la infraestructura: citas creadas, citas rechazadas por validación, y distribución de la duración solicitada. Añade una alerta para "llevamos 30 minutos sin crear ninguna cita en horario comercial".

Ejercicio 3: histórico de DORA con tendencia

Haz que el informe DORA guarde su histórico y muestre la variación respecto a la semana anterior, con flechas de tendencia. Un número aislado no sirve; una tendencia sí.

Soluciones

Solución 1.

## SLO 2: disponibilidad de /api/huecos

| Campo | Valor |
|---|---|
| SLI | Proporción de peticiones a `/api/huecos` sin código 5xx |
| Objetivo | 99,9 % |
| Ventana | 30 días |

### Aritmética del presupuesto

    Tráfico:  20 pet/min
    Ventana:  30 días

    Peticiones = 20 × 60 × 24 × 30 = 864.000

    Presupuesto = (100 % − 99,9 %) = 0,1 %
                = 0,001 × 864.000 = 864 peticiones fallidas

    En tiempo (caída total):
                864 ÷ 20 pet/min = 43,2 minutos cada 30 días
                ≈ 1,44 minutos por día

Nota: el SLO de latencia (99,5 %) permite 1.008 peticiones lentas cada 7 días;
el de disponibilidad permite 864 fallidas cada 30 días. Son presupuestos
INDEPENDIENTES y el más restrictivo manda: agotar cualquiera de los dos
dispara la congelación.
# SLI de disponibilidad
1 - (
  sum(rate(http_peticiones_total{ruta="/api/huecos", codigo=~"5.."}[30d]))
  /
  sum(rate(http_peticiones_total{ruta="/api/huecos"}[30d]))
)

Alerta de burn rate con dos ventanas:

      # La ventana LARGA (1 h) detecta el problema sostenido.
      # La ventana CORTA (5 m) confirma que SIGUE ocurriendo AHORA.
      # Exigir ambas elimina las alertas por un incidente ya resuelto,
      # que es la causa numero uno de desconfianza en las alertas.
      - alert: PresupuestoDisponibilidadQuemandoseRapido
        expr: |
          (
            sum by (entorno) (rate(http_peticiones_total{ruta="/api/huecos",codigo=~"5.."}[1h]))
            / sum by (entorno) (rate(http_peticiones_total{ruta="/api/huecos"}[1h]))
          ) > (14.4 * 0.001)
          and
          (
            sum by (entorno) (rate(http_peticiones_total{ruta="/api/huecos",codigo=~"5.."}[5m]))
            / sum by (entorno) (rate(http_peticiones_total{ruta="/api/huecos"}[5m]))
          ) > (14.4 * 0.001)
        for: 2m
        labels: { severidad: critica, tipo: burn-rate-rapido }
        annotations:
          resumen: 'Presupuesto de disponibilidad quemandose 14,4x mas rapido de lo sostenible'
          descripcion: 'A este ritmo, el presupuesto de 30 dias se agota en ~2 dias.'

      - alert: PresupuestoDisponibilidadQuemandoseLento
        expr: |
          (
            sum by (entorno) (rate(http_peticiones_total{ruta="/api/huecos",codigo=~"5.."}[6h]))
            / sum by (entorno) (rate(http_peticiones_total{ruta="/api/huecos"}[6h]))
          ) > (6 * 0.001)
          and
          (
            sum by (entorno) (rate(http_peticiones_total{ruta="/api/huecos",codigo=~"5.."}[30m]))
            / sum by (entorno) (rate(http_peticiones_total{ruta="/api/huecos"}[30m]))
          ) > (6 * 0.001)
        for: 15m
        labels: { severidad: aviso, tipo: burn-rate-lento }

Los multiplicadores del SRE Workbook: 14,4× consume el presupuesto entero en 2 días (alerta que despierta); lo consume en 5 días (alerta que abre un ticket). El patrón de dos ventanas es lo que hace que la alerta se apague sola cuando el incidente se resuelve, en lugar de seguir sonando por la contaminación de la ventana larga.

Solución 2.

// En src/servidor.js, dentro del manejador de POST /api/citas:
      if (req.method === 'POST' && url.pathname === '/api/citas') {
        const cuerpo = await leerCuerpo(req);
        const duracionSolicitada = cuerpo.inicio && cuerpo.fin
          ? aMinutos(cuerpo.fin) - aMinutos(cuerpo.inicio)
          : 0;
        try {
          const cita = repositorio.crearCita(cuerpo);
          metricas.incrementar('citas_creadas_total', { entorno: ENTORNO });
          // Cubos en MINUTOS: 15, 30, 45, 60, 90, 120.
          metricas.observar('cita_duracion_minutos', {}, duracionSolicitada);
          return responderJson(res, 201, cita);
        } catch (error) {
          metricas.incrementar('citas_rechazadas_total', {
            // La etiqueta es el TIPO de error, no el mensaje: los mensajes
            // son texto libre y harian explotar la cardinalidad.
            motivo: error instanceof RangeError ? 'rango_invalido' : 'formato_invalido',
          });
          throw error;
        }
      }
      - alert: SinCitasCreadas
        # `hour()` devuelve la hora UTC. El `and` con los rangos horarios y
        # el dia de la semana evita que la alerta suene un domingo de noche,
        # cuando cero citas es lo normal.
        expr: |
          (
            sum(increase(citas_creadas_total{entorno="produccion"}[30m])) == 0
            or
            absent(citas_creadas_total{entorno="produccion"})
          )
          and on() (hour() >= 8 and hour() < 18)
          and on() (day_of_week() > 0 and day_of_week() < 6)
        for: 10m
        labels: { severidad: critica, tipo: negocio }
        annotations:
          resumen: 'Ninguna cita creada en 30 minutos, en horario comercial'
          descripcion: >-
            Los HTTP pueden estar en 200 y el producto estar roto igualmente.
            Comprobar el flujo completo de reserva antes que la infraestructura.

Esta última es, de largo, la alerta más valiosa del fichero. Una regresión que rompe el formulario de reserva deja todos los endpoints devolviendo 200 —nadie llega a llamarlos— y ninguna señal de oro se inmuta. La única métrica que se entera es la de negocio.

Solución 3.

// Al final de scripts/dora.js
import { readFile, writeFile, mkdir } from 'node:fs/promises';

const HISTORICO = 'observabilidad/historico-dora.json';

const actual = {
  fecha: new Date().toISOString().slice(0, 10),
  ventanaDias: DIAS,
  frecuenciaSemanal: Number(porSemana.toFixed(2)),
  leadTimeHoras: Number(leadMediana.toFixed(2)),
  cfrPct: Number(cfr.toFixed(2)),
  restauracionMin: Number(restauracionMediana.toFixed(1)),
};

let historico = [];
try {
  historico = JSON.parse(await readFile(HISTORICO, 'utf8'));
} catch { /* primera ejecucion */ }

const anterior = historico.at(-1);
historico.push(actual);
await mkdir('observabilidad', { recursive: true });
await writeFile(HISTORICO, `${JSON.stringify(historico.slice(-52), null, 2)}\n`);

/**
 * Flecha de tendencia. `mejorSiSube` distingue las metricas de rapidez
 * (mas es mejor) de las de estabilidad (menos es mejor): sin ese parametro,
 * un CFR que sube saldria con flecha verde.
 */
function tendencia(actualVal, anteriorVal, mejorSiSube) {
  if (anteriorVal === undefined) return '—';
  const delta = actualVal - anteriorVal;
  if (Math.abs(delta) < 0.001) return '→ igual';
  const mejora = mejorSiSube ? delta > 0 : delta < 0;
  const signo = delta > 0 ? '+' : '';
  return `${mejora ? '🟢 ▲' : '🔴 ▼'} ${signo}${delta.toFixed(1)}`;
}

const tabla = `
### Tendencia respecto a la medición anterior${anterior ? ` (${anterior.fecha})` : ''}

| Métrica | Anterior | Actual | Tendencia |
|---|---|---|---|
| Frecuencia (/semana) | ${anterior?.frecuenciaSemanal ?? '—'} | ${actual.frecuenciaSemanal} | ${tendencia(actual.frecuenciaSemanal, anterior?.frecuenciaSemanal, true)} |
| Lead time (h) | ${anterior?.leadTimeHoras ?? '—'} | ${actual.leadTimeHoras} | ${tendencia(actual.leadTimeHoras, anterior?.leadTimeHoras, false)} |
| CFR (%) | ${anterior?.cfrPct ?? '—'} | ${actual.cfrPct} | ${tendencia(actual.cfrPct, anterior?.cfrPct, false)} |
| Restauración (min) | ${anterior?.restauracionMin ?? '—'} | ${actual.restauracionMin} | ${tendencia(actual.restauracionMin, anterior?.restauracionMin, false)} |

<details><summary>Serie histórica (${historico.length} mediciones)</summary>

\`\`\`
${historico.slice(-12).map((h) =>
  `${h.fecha}  freq=${String(h.frecuenciaSemanal).padStart(5)}/sem  lead=${String(h.leadTimeHoras).padStart(6)}h  cfr=${String(h.cfrPct).padStart(5)}%  mttr=${String(h.restauracionMin).padStart(5)}min`,
).join('\n')}
\`\`\`

</details>
`;

console.log(tabla);
if (process.env.GITHUB_STEP_SUMMARY) {
  await appendFile(process.env.GITHUB_STEP_SUMMARY, tabla);
}

Y en el workflow, para que el histórico persista:

      - name: Commitear el historico
        run: |
          git config user.name  'github-actions[bot]'
          git config user.email 'github-actions[bot]@users.noreply.github.com'
          git add observabilidad/historico-dora.json
          # `|| exit 0`: si no hay cambios, `git commit` sale con 1 y no es un fallo.
          git diff --staged --quiet || git commit -m "chore: métricas DORA $(date -u +%Y-%m-%d)"
          git push

Necesita permissions: contents: write en el job y, si main está protegida, un ruleset bypass para github-actions[bot] o una rama dedicada.

Guardar el histórico en el propio repositorio tiene una virtud que compensa su tosquedad: el dato viaja con el código, se revisa en los PR y no depende de ningún servicio externo que alguien tenga que pagar. Para un equipo pequeño es más que suficiente.

Reto opcional

Sustituye la vigilancia basada en /metricas por una consulta a Prometheus (/api/v1/query con PromQL), que es lo que haría un sistema real: te permite usar percentiles reales en vez de la media, comparar con la semana anterior a la misma hora, y correlacionar varias métricas en una sola expresión. La consulta sería algo como histogram_quantile(0.95, sum by (le) (rate(http_duracion_segundos_bucket{entorno="produccion"}[5m]))) y el criterio de rollback, comparar ese valor con el de antes del despliegue en lugar de con un umbral absoluto. Es la diferencia entre "está lento" y "está más lento que antes de tu cambio", que es la pregunta correcta.

Qué has construido

  • Un registro de métricas propio con contadores, gauges e histogramas acumulativos, en formato Prometheus, sin dependencias, con protección contra la explosión de cardinalidad.
  • Un middleware de instrumentación que mide latencia y clasifica errores de cliente frente a errores de servidor.
  • Una pila de observabilidad con docker compose, aprovisionada como código: Prometheus con dos objetivos etiquetados por entorno y Grafana con datasource y dashboard versionados.
  • Un panel como código con los tres percentiles, el umbral del SLO dibujado y una variable de entorno.
  • Un SLO documentado con los cuatro elementos, su presupuesto de error calculado con aritmética explícita y una política de decisión por tramos.
  • Cuatro reglas de alerta por síntoma con for, runbook y burn rate, validadas con promtool, y la experiencia de ver una pasar por PENDING hasta FIRING.
  • Anotaciones de despliegue en el panel, que hacen visible la correlación entre cambio y degradación.
  • Un job programado que calcula las cuatro métricas DORA de tu propio repositorio y las publica.
  • Un rollback automático por métricas con muestra mínima, ciclos consecutivos y escalado a persona cuando no puede decidir.

Conclusión

El bucle está cerrado en las dos direcciones. Hacia dentro: el sistema te dice cómo está, con un objetivo numérico, un presupuesto que se consume y alertas que suenan por lo que los usuarios notan. Hacia el pipeline: el pipeline se mide a sí mismo, y ahora puedes responder con números a "¿esto está funcionando?". Y las dos direcciones se juntan en el rollback automático, donde una métrica degradada mueve una palanca del pipeline sin que nadie mire.

Ese es también el punto en el que conviene parar y darse cuenta de lo que has construido, porque tiene un problema. Tu pipeline ahora puede desplegar código en producción y revertirlo, solo, sin intervención humana. Tiene credenciales de registro. Tiene un token capaz de lanzar workflows. Ejecuta acciones de terceros que apuntan a tags móviles que sus autores pueden mover. Construye imágenes que corren procesos con dependencias que nadie ha auditado. Y ese GRAFANA_TOKEN que has puesto hace un rato es una credencial de larga vida guardada en una variable que varios jobs pueden leer.

Dicho de otro modo: has construido un sistema muy capaz y muy permisivo, y aún no lo has mirado con ojos de atacante.

En la 07-05 empezamos por ahí: una auditoría del pipeline que tú mismo has construido, con la lista de todo lo que está mal, y su corrección paso a paso. Aplicarás permissions de mínimo privilegio y verás cómo se lee el error cuando falta uno; fijarás las acciones por SHA y automatizarás su actualización; añadirás Gitleaks y cometerás un secreto de prueba a propósito para ejecutar el procedimiento completo de respuesta —rotar primero, limpiar el historial después, y entender por qué en ese orden—; implementarás una política de severidades con jq sobre npm audit --json; activarás CodeQL e introducirás una inyección evidente para verla detectada; escanearás la imagen con Trivy con excepciones que caducan; generarás un SBOM, firmarás la imagen con Cosign y verificarás la firma antes de desplegar; comprobarás el enmascarado de secretos y por qué no es una garantía; y verás el ataque concreto que hace de pull_request_target la trampa más peligrosa de GitHub Actions.

Curso de CI/CD: Integración y Despliegue Continuo

Módulo 1: Introducción a CI/CD

Módulo 2: Integración Continua (CI)

Módulo 3: Despliegue Continuo (CD)

Módulo 4: Prácticas Avanzadas de CI/CD

Módulo 5: Implementación de CI/CD en Proyectos Reales

Módulo 6: Herramientas y Tecnologías

Módulo 7: Ejercicios Prácticos

Módulo 8: Recursos Adicionales

© Copyright 2026. Todos los derechos reservados