El teu pipeline desplega i sap tornar enrere, però és cec. El smoke test de la 07-03 comprova que el servei ha respost bé durant els trenta segons posteriors al desplegament; sobre el que passa al cap de vint minuts, quan entra trànsit real, no diu res. I hi ha una pregunta encara més incòmoda que tampoc saps contestar: el pipeline està millorant les coses? Quantes vegades has desplegat aquesta setmana? Quant triga un canvi des del commit fins a producció? Quin percentatge de desplegaments acaba en rollback? Sense aquests números, el pipeline és un acte de fe.

En aquest laboratori construeixes les dues direccions del bucle de retroalimentació. Cap a dins del sistema: instrumentaràs Mini-Reservalia perquè exposi mètriques, aixecaràs Prometheus i Grafana amb docker compose, definiràs un SLO amb el seu pressupost d'error calculat a mà, escriuràs una alerta per símptoma i la dispararàs expressament. Cap al pipeline: marcaràs els desplegaments al panell per poder correlacionar «hem desplegat» amb «ha empitjorat», calcularàs les quatre mètriques DORA del teu propi repositori amb un job programat, i —el tancament del bucle— faràs que el cd.yml observi les mètriques després de desplegar i dispari el rollback ell sol si la cosa es torça.

Contingut

  1. Objectiu, requisits previs i punt de partida
  2. Instrumentar el servidor: comptadors i histogrames
  3. L'endpoint /metriques en format Prometheus
  4. Les quatre senyals d'or sobre aquestes mètriques
  5. Prometheus i Grafana amb docker compose
  6. El panell com a codi
  7. L'SLO i el seu pressupost d'error, amb l'aritmètica
  8. Regles d'alerta per símptoma
  9. Disparar l'alerta expressament
  10. Marcar els desplegaments al panell
  11. Tancar el bucle: les quatre DORA del teu repositori
  12. Rollback automàtic per mètriques
  13. Verificació final
  14. Errors Comuns i Consells
  15. Exercicis
  16. Conclusió

  1. Objectiu, requisits previs i punt de partida

Objectiu. En acabar tindràs Mini-Reservalia exposant mètriques en format Prometheus, un panell de Grafana versionat al repositori, un SLO amb pressupost d'error, una alerta que has vist disparar-se, un informe setmanal automàtic amb les quatre mètriques DORA del teu repositori, i un cd.yml que reverteix sol quan les mètriques empitjoren després d'un desplegament.

Requisits previs. Les lliçons 07-01 a 07-03. Docker i docker compose funcionant. El cd.yml desplegant i el rollback.yml operatiu.

Punt de partida. Mini-Reservalia desplegada pel pipeline a staging (port 3001) i produccio (port 3002).

git checkout main && git pull
git checkout -b observabilitat

  1. Instrumentar el servidor: comptadors i histogrames

Ens calen dos tipus de mètrica i convé entendre'n la diferència abans d'escriure codi:

Tipus Què és Exemple aquí Com es consulta
Counter Número que només puja; es reinicia en reiniciar el procés Peticions totals per ruta i codi rate() sobre una finestra
Gauge Número que puja i baixa Peticions en vol, segons actiu Valor directe
Histogram Comptadors acumulatius per «cubell» de valor, més suma i total Durada de les peticions histogram_quantile() per als percentils

Un comptador de latència mitjana seria inútil: la mitjana amaga exactament el que importa. Si 99 peticions triguen 10 ms i una triga 5 segons, la mitjana és de 60 ms i sembla estupenda, mentre un usuari de cada cent se'n va. L'histograma permet preguntar pel percentil 95 o 99, que és on viu el dolor real. La 03-06 ho explicava; ara ho implementaràs.

src/metriques.js:

// src/metriques.js
// Registre de metriques en format Prometheus, sense dependencies.
//
// Prometheus es text pla: cada linia es
//   nom{etiqueta="valor",...} numero
// precedida de comentaris # HELP i # TYPE. Amb aixo n hi ha prou perque
// Prometheus ho raspi (scrape) i ho emmagatzemi com a serie temporal.

/** Limits dels cubells de l histograma, en segons. */
const CUBELLS = [0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10];

export class Metriques {
  /** Comptadors: clau = "nom|etiquetes serialitzades" -> numero */
  #comptadors = new Map();
  /** Histogrames: clau -> { cubells: number[], suma: number, total: number } */
  #histogrames = new Map();
  #inici = Date.now();
  #enVol = 0;

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

  incrementar(nom, etiquetes = {}, quantitat = 1) {
    const clau = this.#clau(nom, etiquetes);
    this.#comptadors.set(clau, (this.#comptadors.get(clau) ?? 0) + quantitat);
  }

  observar(nom, etiquetes = {}, valorSeg = 0) {
    const clau = this.#clau(nom, etiquetes);
    let h = this.#histogrames.get(clau);
    if (!h) {
      h = { cubells: new Array(CUBELLS.length).fill(0), suma: 0, total: 0 };
      this.#histogrames.set(clau, h);
    }
    // Els cubells de Prometheus son ACUMULATIUS: el cubell le="0.1" compta
    // totes les observacions <= 0.1, no nomes les de l interval.
    for (let i = 0; i < CUBELLS.length; i++) {
      if (valorSeg <= CUBELLS[i]) h.cubells[i]++;
    }
    h.suma += valorSeg;
    h.total++;
  }

  entrada() { this.#enVol++; }
  sortida() { this.#enVol--; }

  /** Serialitza tot el registre en el format d exposicio de Prometheus. */
  exposar({ versio = 'dev', entorn = 'desconegut' } = {}) {
    const linies = [];

    linies.push(
      '# HELP mini_reservalia_info Informacio de la instancia (valor sempre 1)',
      '# TYPE mini_reservalia_info gauge',
      `mini_reservalia_info{versio="${versio}",entorn="${entorn}"} 1`,
      '',
      '# HELP mini_reservalia_actiu_segons Segons des de l arrencada del proces',
      '# TYPE mini_reservalia_actiu_segons gauge',
      `mini_reservalia_actiu_segons ${((Date.now() - this.#inici) / 1000).toFixed(0)}`,
      '',
      '# HELP http_peticions_en_vol Peticions HTTP en curs ara mateix',
      '# TYPE http_peticions_en_vol gauge',
      `http_peticions_en_vol ${this.#enVol}`,
      '',
    );

    // Comptadors agrupats per nom de metrica.
    const perNom = new Map();
    for (const [clau, valor] of this.#comptadors) {
      const [nom, etiquetes] = clau.split('|');
      if (!perNom.has(nom)) perNom.set(nom, []);
      perNom.get(nom).push([etiquetes, valor]);
    }
    for (const [nom, series] of perNom) {
      linies.push(`# HELP ${nom} Comptador acumulat`, `# TYPE ${nom} counter`);
      for (const [etiquetes, valor] of series.sort()) {
        linies.push(etiquetes ? `${nom}{${etiquetes}} ${valor}` : `${nom} ${valor}`);
      }
      linies.push('');
    }

    // Histogrames: _bucket (acumulatius, amb le="+Inf"), _sum i _count.
    const histPerNom = new Map();
    for (const [clau, h] of this.#histogrames) {
      const [nom, etiquetes] = clau.split('|');
      if (!histPerNom.has(nom)) histPerNom.set(nom, []);
      histPerNom.get(nom).push([etiquetes, h]);
    }
    for (const [nom, series] of histPerNom) {
      linies.push(`# HELP ${nom} Distribucio de durades en segons`, `# TYPE ${nom} histogram`);
      for (const [etiquetes, h] of series.sort()) {
        const sufix = etiquetes ? `,${etiquetes}` : '';
        for (let i = 0; i < CUBELLS.length; i++) {
          linies.push(`${nom}_bucket{le="${CUBELLS[i]}"${sufix}} ${h.cubells[i]}`);
        }
        linies.push(`${nom}_bucket{le="+Inf"${sufix}} ${h.total}`);
        linies.push(etiquetes ? `${nom}_sum{${etiquetes}} ${h.suma.toFixed(6)}` : `${nom}_sum ${h.suma.toFixed(6)}`);
        linies.push(etiquetes ? `${nom}_count{${etiquetes}} ${h.total}` : `${nom}_count ${h.total}`);
      }
      linies.push('');
    }

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

  reiniciar() {
    this.#comptadors.clear();
    this.#histogrames.clear();
    this.#enVol = 0;
  }
}

export const metriques = new Metriques();

I ara el middleware. A src/servidor.js, embolcalla el gestor:

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

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

/**
 * Normalitza la ruta per fer-la servir com a ETIQUETA.
 *
 * CRITIC: no facis servir mai la URL crua com a etiqueta. Cada valor diferent crea
 * una SERIE TEMPORAL nova a Prometheus. Amb `/api/forats?data=...` tindries una
 * serie per data consultada: milers de series, memoria disparada i consultes
 * impossibles. Es l error anomenat "explosio de cardinalitat" i tomba
 * instal.lacions de Prometheus senceres.
 */
function rutaNormalitzada(pathname) {
  const conegudes = ['/salut', '/metriques', '/api/forats', '/api/cites'];
  return conegudes.includes(pathname) ? pathname : '/altres';
}

function instrumentar(gestor) {
  return async (req, res) => {
    const inici = process.hrtime.bigint();
    metriques.entrada();

    // 'finish' s emet quan la resposta s ha enviat del tot,
    // que es el moment correcte per mesurar la latencia d extrem a extrem.
    res.once('finish', () => {
      const duracioSeg = Number(process.hrtime.bigint() - inici) / 1e9;
      const url = new URL(req.url, 'http://intern');
      const etiquetes = {
        metode: req.method,
        ruta: rutaNormalitzada(url.pathname),
        codi: String(res.statusCode),
      };
      metriques.incrementar('http_peticions_total', etiquetes);
      metriques.observar('http_duracio_segons', {
        metode: etiquetes.metode,
        ruta: etiquetes.ruta,
      }, duracioSeg);
      if (res.statusCode >= 500) {
        metriques.incrementar('http_errors_total', { ruta: etiquetes.ruta, tipus: 'servidor' });
      } else if (res.statusCode >= 400) {
        metriques.incrementar('http_errors_total', { ruta: etiquetes.ruta, tipus: 'client' });
      }
      metriques.sortida();
    });

    return gestor(req, res);
  };
}

  1. L'endpoint /metriques en format Prometheus

Embolcalla el gestor en crear el servidor i afegeix-hi la ruta d'exposició. Prometheus no rep res: hi va ell a buscar-ho (model pull), fent un GET a /metriques cada pocs segons. Aquesta inversió és el que fa que l'aplicació no necessiti saber res del sistema de monitoratge: només ha de publicar un text.

export function crearServidor({ repositori, horari = HORARI_PER_DEFECTE } = {}) {
  if (!repositori) throw new Error('crearServidor requereix un repositori');

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

    // --- Endpoint de metriques ---
    if (req.method === 'GET' && url.pathname === '/metriques') {
      const cos = metriques.exposar({ versio: VERSIO, entorn: ENTORN });
      res.writeHead(200, {
        // Aquest content-type exacte es el que espera Prometheus.
        'content-type': 'text/plain; version=0.0.4; charset=utf-8',
        'content-length': Buffer.byteLength(cos),
      });
      return res.end(cos);
    }

    // --- Retard artificial per a l exercici de l apartat 9 ---
    // Controlat per variable d entorn: no s activa mai per accident.
    const retard = Number(process.env.RETARD_ARTIFICIAL_MS ?? 0);
    if (retard > 0 && url.pathname === '/api/forats') {
      await new Promise((r) => setTimeout(r, retard));
    }

    /* ... la resta de rutes, sense canvis ... */
  };

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

Afegeix-hi també la seva prova, que la cobertura continua tenint llindar:

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

describe('registre de metriques', () => {
  test('un comptador s acumula per combinacio d etiquetes', () => {
    const m = new Metriques();
    m.incrementar('http_peticions_total', { ruta: '/salut', codi: '200' });
    m.incrementar('http_peticions_total', { ruta: '/salut', codi: '200' });
    m.incrementar('http_peticions_total', { ruta: '/salut', codi: '500' });
    const text = m.exposar();
    assert.match(text, /http_peticions_total\{codi="200",ruta="\/salut"\} 2/);
    assert.match(text, /http_peticions_total\{codi="500",ruta="\/salut"\} 1/);
  });

  test('els cubells de l histograma son ACUMULATIUS', () => {
    const m = new Metriques();
    m.observar('http_duracio_segons', { ruta: '/api/forats' }, 0.03);
    const text = m.exposar();
    // 0.03 s NO entra a le=0.025 pero SI a le=0.05 i a tots els majors.
    assert.match(text, /_bucket\{le="0.025",ruta="\/api\/forats"\} 0/);
    assert.match(text, /_bucket\{le="0.05",ruta="\/api\/forats"\} 1/);
    assert.match(text, /_bucket\{le="\+Inf",ruta="\/api\/forats"\} 1/);
  });

  test('l histograma exposa _sum i _count', () => {
    const m = new Metriques();
    m.observar('http_duracio_segons', {}, 0.1);
    m.observar('http_duracio_segons', {}, 0.3);
    const text = m.exposar();
    assert.match(text, /http_duracio_segons_count 2/);
    assert.match(text, /http_duracio_segons_sum 0\.400000/);
  });

  test('les etiquetes se sanegen per no trencar el format', () => {
    const m = new Metriques();
    m.incrementar('prova_total', { ruta: 'amb"cometes' });
    assert.doesNotMatch(m.exposar(), /ruta="amb"cometes"/);
  });
});

Comprova-ho en local:

npm test                      # les proves noves en verd
BASE_DADES='sqlite:/tmp/m.db' ENTORN=local npm start &
for i in $(seq 1 20); do curl -s "localhost:3000/api/forats?data=2026-03-02" > /dev/null; done
curl -s localhost:3000/api/forats > /dev/null      # un 400
curl -s localhost:3000/metriques | head -30

Què has de veure:

# HELP mini_reservalia_info Informacio de la instancia (valor sempre 1)
# TYPE mini_reservalia_info gauge
mini_reservalia_info{versio="dev",entorn="local"} 1

# HELP mini_reservalia_actiu_segons Segons des de l arrencada del proces
# TYPE mini_reservalia_actiu_segons gauge
mini_reservalia_actiu_segons 34

# HELP http_peticions_en_vol Peticions HTTP en curs ara mateix
# TYPE http_peticions_en_vol gauge
http_peticions_en_vol 1

# HELP http_peticions_total Comptador acumulat
# TYPE http_peticions_total counter
http_peticions_total{codi="200",metode="GET",ruta="/api/forats"} 20
http_peticions_total{codi="400",metode="GET",ruta="/api/forats"} 1

# HELP http_duracio_segons Distribucio de durades en segons
# TYPE http_duracio_segons histogram
http_duracio_segons_bucket{le="0.005",metode="GET",ruta="/api/forats"} 19
...

  1. Les quatre senyals d'or sobre aquestes mètriques

La 03-06 presentava les quatre senyals d'or de l'SRE. Aquí les tens, mapades a consultes concretes que pots copiar i enganxar:

Senyal Pregunta que respon Consulta PromQL
Latència Quant triguen les peticions que sí que funcionen? histogram_quantile(0.95, sum by (le, ruta) (rate(http_duracio_segons_bucket[5m])))
Trànsit Quanta demanda hi ha? sum by (ruta) (rate(http_peticions_total[5m]))
Errors Quina fracció falla? sum(rate(http_peticions_total{codi=~"5.."}[5m])) / sum(rate(http_peticions_total[5m]))
Saturació Com de ple està el sistema? http_peticions_en_vol

Dos matisos que separen un panell útil d'un de decoratiu:

  • Latència només de les peticions reeixides. Un 500 retornat en 2 ms millora el teu percentil 95 i et fa creure que tot va ràpid. Filtra: http_duracio_segons_bucket{codi=~"2.."} si afegeixes el codi com a etiqueta de l'histograma (a canvi de cardinalitat).
  • Errors 5xx, no 4xx. Un 400 perquè el client ha enviat una data mal formada no és una fallada teva: és la teva validació funcionant. Barrejar-los fa que la teva taxa d'error pugi quan algú escaneja la teva API, i t'acostuma a ignorar-la. Per això http_errors_total separa tipus="client" de tipus="servidor".

  1. Prometheus i Grafana amb docker compose

observabilitat/docker-compose.yml:

# observabilitat/docker-compose.yml
# Pila d observabilitat local: Prometheus (recull i emmagatzema) + Grafana (dibuixa).
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'
      # Necessari per recarregar la configuracio sense reiniciar:
      #   curl -X POST http://localhost:9090/-/reload
      - '--web.enable-lifecycle'
    volumes:
      - ./prometheus.yml:/etc/prometheus/prometheus.yml:ro
      - ./alertes.yml:/etc/prometheus/alertes.yml:ro
      - dades-prometheus:/prometheus
    # Permet que Prometheus arribi als contenidors de l app publicats
    # a l amfitrio (Linux; a Docker Desktop ja existeix 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:
      # Aprovisionament com a codi: Grafana llegeix aquests fitxers en arrencar.
      # RES no es configura clicant a la interficie: si no es al repositori,
      # no existeix (mateix principi que la infraestructura com a codi, 03-03).
      - ./grafana/provisioning:/etc/grafana/provisioning:ro
      - ./grafana/dashboards:/var/lib/grafana/dashboards:ro
      - dades-grafana:/var/lib/grafana
    depends_on: [prometheus]

volumes:
  dades-prometheus:
  dades-grafana:

observabilitat/prometheus.yml:

# observabilitat/prometheus.yml
global:
  scrape_interval: 15s       # cada quant es raspen els objectius
  evaluation_interval: 15s   # cada quant s avaluen les regles d alerta
  external_labels:
    projecte: mini-reservalia

rule_files:
  - /etc/prometheus/alertes.yml

scrape_configs:
  # 1. El mateix Prometheus (sempre util per saber si ell ha caigut)
  - job_name: prometheus
    static_configs:
      - targets: ['localhost:9090']

  # 2. Mini-Reservalia, un objectiu per entorn.
  #    L etiqueta `entorn` permet comparar staging i produccio
  #    al mateix panell, que es com es veu una regressio abans que faci mal.
  - job_name: mini-reservalia
    metrics_path: /metriques
    scrape_interval: 10s
    scrape_timeout: 5s
    static_configs:
      - targets: ['host.docker.internal:3001']
        labels: { entorn: staging }
      - targets: ['host.docker.internal:3002']
        labels: { entorn: produccio }
    relabel_configs:
      # `instance` per defecte es "amfitrio:port", il.legible als panells.
      - source_labels: [entorn]
        target_label: instance

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

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

observabilitat/grafana/provisioning/dashboards/dashboards.yml:

apiVersion: 1
providers:
  - name: 'mini-reservalia'
    folder: 'Mini-Reservalia'
    type: file
    disableDeletion: false
    updateIntervalSeconds: 30
    allowUiUpdates: false      # els canvis es fan al repositori, no a la UI
    options:
      path: /var/lib/grafana/dashboards

Aixeca la pila:

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

Què has de veure. A http://localhost:9090/targets, la taula d'objectius:

Endpoint State Labels
http://host.docker.internal:3001/metriques UP entorn="staging"
http://host.docker.internal:3002/metriques UP entorn="produccio"

Si algun està DOWN amb connection refused, el contenidor d'aquell entorn no s'està executant: desplega'l amb ./scripts/desplegar.sh com a la 07-03.

Genera trànsit i prova una consulta a http://localhost:9090/graph:

for i in $(seq 1 200); do
  curl -s "localhost:3002/api/forats?data=2026-03-02&duracio=60" > /dev/null
  sleep 0.2
done
sum by (entorn) (rate(http_peticions_total[1m]))

Què has de veure: una línia amb un valor proper a 5 peticions per segon mentre dura el bucle.

  1. El panell com a codi

Un dashboard de Grafana és JSON. Que aquest JSON sigui al repositori i s'aprovisioni automàticament significa que el panell es revisa en un PR, es versiona i es restaura sol si algú el trenca. Un panell construït a mà a la interfície és coneixement que existeix en una base de dades de la qual ningú no fa còpia de seguretat.

No enganxarem aquí les 500 ratlles d'un dashboard complet. Veurem un panell sencer, que és on hi ha l'ensenyament, i descriurem la resta.

observabilitat/grafana/dashboards/mini-reservalia.json (fragment amb un panell complet):

{
  "uid": "mini-reservalia",
  "title": "Mini-Reservalia · Senyals d'or",
  "tags": ["mini-reservalia", "slo"],
  "timezone": "browser",
  "refresh": "10s",
  "time": { "from": "now-1h", "to": "now" },
  "templating": {
    "list": [
      {
        "name": "entorn",
        "type": "query",
        "datasource": { "type": "prometheus", "uid": "prometheus-mini" },
        "query": "label_values(http_peticions_total, entorn)",
        "current": { "text": "produccio", "value": "produccio" },
        "includeAll": false
      }
    ]
  },
  "panels": [
    {
      "id": 1,
      "type": "timeseries",
      "title": "Latencia de /api/forats (p50 · p95 · p99)",
      "description": "Percentils calculats sobre els cubells de l histograma. La linia vermella es l objectiu de l 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_duracio_segons_bucket{ruta=\"/api/forats\", entorn=\"$entorn\"}[5m])))",
          "legendFormat": "p50"
        },
        {
          "refId": "B",
          "expr": "histogram_quantile(0.95, sum by (le) (rate(http_duracio_segons_bucket{ruta=\"/api/forats\", entorn=\"$entorn\"}[5m])))",
          "legendFormat": "p95"
        },
        {
          "refId": "C",
          "expr": "histogram_quantile(0.99, sum by (le) (rate(http_duracio_segons_bucket{ruta=\"/api/forats\", entorn=\"$entorn\"}[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": "Desplegaments",
        "datasource": { "type": "prometheus", "uid": "prometheus-mini" },
        "enable": true,
        "iconColor": "rgba(0, 211, 255, 1)",
        "expr": "changes(mini_reservalia_actiu_segons{entorn=\"$entorn\"}[2m]) > 0",
        "titleFormat": "Desplegament",
        "textFormat": "Nova versio a {{entorn}}"
      }
    ]
  },
  "schemaVersion": 39,
  "version": 1
}

Llegeix-lo amb atenció, perquè en aquest únic panell hi ha totes les decisions que importen:

Element Per què hi és
histogram_quantile(0.95, ...) sobre rate(..._bucket[5m]) L'única manera correcta de treure percentils d'un histograma de Prometheus. rate abans de histogram_quantile, mai al revés
sum by (le) Agrega les instàncies conservant l'etiqueta le. Si la perds, histogram_quantile retorna NaN; és l'error número u
Els tres percentils junts El p50 diu com va el cas típic; el p99 diu com va l'1 % pitjor. La distància entre tots dos és el senyal d'un problema de cues
thresholds a 0.3 L'objectiu de l'SLO dibuixat al panell. Un panell sense la línia de l'objectiu obliga a recordar el número
Variable $entorn El mateix panell serveix per a staging i per a producció
annotations amb changes(...actiu_segons...) Marca els desplegaments: actiu_segons es reinicia en arrencar el procés
unit: "s" Sense unitat, Grafana mostra 0.087 i cal traduir-ho mentalment cada vegada

Els altres panells del dashboard, amb la seva consulta (construeix-los com a exercici o copia'ls del mateix patró):

Panell Tipus Consulta
Trànsit per ruta timeseries sum by (ruta) (rate(http_peticions_total{entorn="$entorn"}[5m]))
Taxa d'error 5xx stat sum(rate(http_peticions_total{codi=~"5..",entorn="$entorn"}[5m])) / sum(rate(http_peticions_total{entorn="$entorn"}[5m]))
Peticions en vol timeseries http_peticions_en_vol{entorn="$entorn"}
Compliment de l'SLO (7 d) gauge sum(rate(http_duracio_segons_bucket{le="0.25",ruta="/api/forats",entorn="$entorn"}[7d])) / sum(rate(http_duracio_segons_count{ruta="/api/forats",entorn="$entorn"}[7d]))
Pressupost d'error restant gauge 1 - ((1 - <l'anterior>) / 0.005)
Versió desplegada stat mini_reservalia_info{entorn="$entorn"} amb legend {{versio}}

Obre http://localhost:3000 (admin/admin), ves a Dashboards → Mini-Reservalia. Què has de veure: el dashboard ja existeix sense haver importat res. Modifica'l des de la interfície: no et deixarà desar-lo (allowUiUpdates: false). Això és intencionat, i és la diferència entre un panell que és codi i un que és un record.

  1. L'SLO i el seu pressupost d'error, amb l'aritmètica

Un SLO sense pressupost d'error calculat és un desig. Farem els números explícits.

L'SLO de Mini-Reservalia:

El 99,5 % de les peticions a /api/forats han de respondre correctament en menys de 300 ms, mesurat sobre una finestra mòbil de 7 dies.

Quatre elements, i tots quatre hi han de ser: l'indicador (latència de /api/forats), el llindar (300 ms), l'objectiu (99,5 %) i la finestra (7 dies). Un SLO al qual li falti qualsevol d'ells no es pot avaluar.

Per què 99,5 % i no 99,99 %. Cada nou addicional multiplica el cost per un factor proper a deu: redundància, guàrdies, complexitat. Mini-Reservalia és una eina de reserves per a petits negocis; una petició lenta de cada dues-centes és perfectament tolerable i ningú no cancel·la una subscripció per això. Triar l'objectiu assolible més baix que manté contents els usuaris és una decisió d'enginyeria, no de mandra.

El pressupost d'error, amb l'aritmètica completa:

Transit observat:       20 peticions/minut a /api/forats
Finestra:               7 dies

Peticions a la finestra:
    20 pet/min × 60 min × 24 h × 7 d = 201.600 peticions

Objectiu: 99,5 % correctes i rapides
    Pressupost d error = 100 % − 99,5 % = 0,5 %
    0,005 × 201.600 = 1.008 peticions

  → Ens podem permetre 1.008 peticions lentes o fallides en 7 dies.

Traduït a temps, que és com s'entén de debò:

Si TOTES les peticions fallen durant una caiguda total:
    1.008 peticions ÷ 20 pet/min = 50,4 minuts

  → El pressupost sencer equival a uns 50 minuts de caiguda completa
    cada 7 dies. O, repartit: unes 6 peticions lentes cada hora.

I ara la part que converteix el pressupost en una eina de decisió:

Pressupost consumit Què significa Què es fa
< 50 % Marge de sobres Es desplega amb normalitat. Si el consum és crònicament baix, l'SLO és massa laxe: puja'l
50-75 % Atenció Es continua desplegant, però les millores de fiabilitat pugen de prioritat
75-100 % Alerta Només canvis de risc baix. Els que toquen la ruta afectada, en canary
> 100 % (esgotat) S'ha incomplert Congelació de funcionalitat: l'equip treballa en fiabilitat fins a recuperar marge

Aquest és el valor real del pressupost d'error: converteix «despleguem el divendres?» en una pregunta amb resposta numèrica en lloc d'en una discussió d'opinions. I funciona en les dues direccions: amb el 90 % del pressupost intacte, la resposta és «sí, endavant», i això també s'ha de dir.

Registra'l al repositori, observabilitat/SLO.md:

# SLO de Mini-Reservalia

| Camp | Valor |
|---|---|
| Servei | Mini-Reservalia · API |
| Indicador (SLI) | Proporció de peticions a `/api/forats` amb codi 2xx i latència < 300 ms |
| Objectiu (SLO) | 99,5 % |
| Finestra | 7 dies mòbils |
| Pressupost d'error | 0,5 % ≈ 1.008 peticions ≈ 50 min de caiguda total |
| Propietari | Equip de plataforma |
| Revisió | Trimestral |

## Consulta de l'SLI

sum(rate(http_duracio_segons_bucket{le="0.25", ruta="/api/forats", codi=~"2.."}[7d])) / sum(rate(http_duracio_segons_count{ruta="/api/forats"}[7d]))

> Nota: es fa servir el cubell `le="0.25"` perquè és el límit de cubell més proper
> per sota de 300 ms. Els histogrames només poden respondre sobre els límits que
> existeixen. Si l'SLO fos exactament 300 ms, caldria **afegir un cubell de
> 0,3** a `src/metriques.js`. Aquesta és la contrapartida real dels histogrames:
> els cubells s'han de triar abans de saber què preguntaràs.

## Política del pressupost

- < 50 % consumit: desplegament normal.
- 50-75 %: la fiabilitat puja de prioritat al backlog.
- 75-100 %: només canvis de risc baix, en canary.
- \> 100 %: congelació de funcionalitat fins a recuperar marge.

Aquest avís sobre el cubell de 0,25 no és un detall menor: és el tipus de cosa que es descobreix tres mesos després de definir l'SLO, quan ja hi ha dades històriques que no es poden recalcular. Afegeix el cubell 0.3 a CUBELLS a src/metriques.js ara que hi ets a temps.

  1. Regles d'alerta per símptoma

La regla d'or de la 03-06: alerta sobre símptomes, no sobre causes. «La CPU està al 90 %» no és un problema si ningú no ho nota; «el percentil 95 de latència fa cinc minuts que està per sobre de 300 ms» sí que ho és. Alertar sobre causes produeix soroll; alertar sobre símptomes produeix trucades que valen la pena.

observabilitat/alertes.yml:

# observabilitat/alertes.yml
groups:
  - name: mini-reservalia-simptomes
    interval: 30s
    rules:
      # ---------------------------------------------------------------
      # SÍMPTOMA 1: els usuaris esperen massa.
      # ---------------------------------------------------------------
      - alert: LatenciaAltaForats
        expr: |
          histogram_quantile(0.95,
            sum by (le, entorn) (
              rate(http_duracio_segons_bucket{ruta="/api/forats"}[5m])
            )
          ) > 0.3
        # `for` es el que separa una alerta util d un generador de soroll:
        # la condicio s ha de mantenir 5 minuts seguits. Un pic de 20 s
        # durant un desplegament no desperta ningu.
        for: 5m
        labels:
          severitat: avis
          equip: plataforma
          slo: latencia-forats
        annotations:
          resum: 'p95 de /api/forats per sobre de 300 ms a {{ $labels.entorn }}'
          descripcio: >-
            El percentil 95 fa 5 minuts que es a {{ $value | humanizeDuration }},
            per sobre de l objectiu de 300 ms de l SLO.
            Pressupost d error en risc.
          runbook: 'https://github.com/OWNER/mini-reservalia/blob/main/observabilitat/RUNBOOK.md#latencia-alta'

      # ---------------------------------------------------------------
      # SÍMPTOMA 2: el servei retorna errors propis.
      # ---------------------------------------------------------------
      - alert: TaxaErrorsAlta
        expr: |
          (
            sum by (entorn) (rate(http_peticions_total{codi=~"5.."}[5m]))
            /
            sum by (entorn) (rate(http_peticions_total[5m]))
          ) > 0.01
        for: 3m
        labels:
          severitat: critica
          equip: plataforma
        annotations:
          resum: 'Mes de l 1 % d errors 5xx a {{ $labels.entorn }}'
          descripcio: 'Taxa actual: {{ $value | humanizePercentage }}. Llindar: 1 %.'
          runbook: 'https://github.com/OWNER/mini-reservalia/blob/main/observabilitat/RUNBOOK.md#errors-5xx'

      # ---------------------------------------------------------------
      # SÍMPTOMA 3: no hi ha servei en absolut.
      # ---------------------------------------------------------------
      - alert: ServeiCaigut
        expr: up{job="mini-reservalia"} == 0
        for: 1m
        labels:
          severitat: critica
        annotations:
          resum: 'Mini-Reservalia no respon a {{ $labels.entorn }}'
          descripcio: 'Prometheus no pot raspar /metriques des de fa 1 minut.'

      # ---------------------------------------------------------------
      # SÍMPTOMA 4 (predictiu): el pressupost d error s esgota rapid.
      # Aixo es "burn rate": no alerta per estar malament, alerta per anar
      # cami d estar-ho. Es el que permet actuar abans de l incompliment.
      # ---------------------------------------------------------------
      - alert: PressupostErrorEsgotantseRapid
        expr: |
          (
            1 - (
              sum by (entorn) (rate(http_duracio_segons_bucket{le="0.3", ruta="/api/forats"}[1h]))
              /
              sum by (entorn) (rate(http_duracio_segons_count{ruta="/api/forats"}[1h]))
            )
          ) > (14.4 * 0.005)
        for: 2m
        labels:
          severitat: avis
        annotations:
          resum: 'Consum accelerat del pressupost d error a {{ $labels.entorn }}'
          descripcio: >-
            Al ritme de l ultima hora, el pressupost de 7 dies s esgotaria
            en unes 12 hores (burn rate 14,4x). Llindar estandard de Google SRE.

El 14.4 de l'última alerta mereix explicació, perquè sembla un número màgic: és el factor que esgota un pressupost de 30 dies en 2 dies, i és el multiplicador estàndard de l'SRE Workbook per a l'alerta de pàgina ràpida. Amb la nostra finestra de 7 dies, un burn rate de 14,4× esgota el pressupost en unes 12 hores. La idea és que t'avisi quan encara pots fer alguna cosa, no quan ja has incomplert.

Recarrega Prometheus i comprova-ho:

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

# Validar la sintaxi ABANS de recarregar (fes-ho sempre):
docker run --rm -v "$PWD/observabilitat:/o" --entrypoint promtool \
  prom/prometheus:v2.53.0 check rules /o/alertes.yml
# Checking /o/alertes.yml
#   SUCCESS: 4 rules found

Aquest promtool check rules hauria de ser al teu ci.yml: les regles d'alerta són codi i es validen com a codi. Una regla amb un error de sintaxi fa que Prometheus ignori tot el fitxer, i te n'assabentes el dia que necessitaves l'alerta.

Què has de veure a http://localhost:9090/alerts: les quatre regles en estat Inactive (verd).

  1. Disparar l'alerta expressament

Una alerta que no has vist mai disparar-se és una hipòtesi. Comprovem-la.

# 1. Tornar a desplegar staging amb retard artificial de 500 ms
docker rm -f mini-reservalia-staging
docker run -d --name mini-reservalia-staging \
  -p 3001:3000 \
  -e ENTORN=staging \
  -e RETARD_ARTIFICIAL_MS=500 \
  -e APP_VERSION=lenta \
  ghcr.io/EL_TEU_USUARI/mini-reservalia@sha256:EL_TEU_DIGEST

# 2. Generar transit sostingut durant 7 minuts
END=$((SECONDS+420))
while [ $SECONDS -lt $END ]; do
  curl -s "localhost:3001/api/forats?data=2026-03-02" > /dev/null
  sleep 0.5
done

Què has de veure, cronològicament:

Moment http://localhost:9090/alerts Grafana
t = 0 LatenciaAltaForats Inactive El p95 puja de cop a ~0,5 s
t ≈ 40 s PENDING (groc) — la condició es compleix però encara no fa 5 min La línia creua el llindar vermell de 0,3
t ≈ 5 min 40 s FIRING (vermell) La línia continua per sobre
Després de treure el retard, t + ~1 min Torna a Inactive La línia baixa

Aquest estat intermedi PENDING és el for: 5m. Val la pena veure'l: és la diferència entre un sistema d'alertes que la gent atén i un que la gent silencia. Sense for, un pic de tres segons durant un desplegament rutinari hauria disparat l'alerta.

Prova també l'alerta d'errors:

docker rm -f mini-reservalia-staging   # ServeiCaigut passa a FIRING en 1 min

I restaura la versió bona:

IMATGE=ghcr.io/EL_TEU_USUARI/mini-reservalia@sha256:BO \
ENTORN=staging PORT_AMFITRIO=3001 ./scripts/desplegar.sh

Escriu a més el runbook al qual apunten les anotacions, observabilitat/RUNBOOK.md, perquè una alerta sense runbook obliga a improvisar a les 3 de la matinada:

# Runbook de Mini-Reservalia

## LatenciaAltaForats

**Símptoma:** p95 de `/api/forats` > 300 ms durant 5 minuts.
**Impacte:** els usuaris veuen la llista de forats amb retard perceptible. Consumeix pressupost d'error.

**Diagnòstic, en ordre:**
1. Hi ha hagut un desplegament recent? Mira les anotacions del panell.
   `gh run list --workflow=cd.yml --limit 5`
2. Ha pujat el trànsit? Panell «Trànsit per ruta». Si sí, és capacitat, no regressió.
3. Estan altes les peticions en vol? Panell «Saturació». Si sí, hi ha encuament.
4. `docker logs --tail 200 mini-reservalia-produccio`

**Mitigació:**
- Si coincideix amb un desplegament: **rollback primer, investigar després**.
  `gh workflow run rollback.yml -f entorn=produccio -f digest=<anterior> -f motiu="LatenciaAltaForats"`
- Si és càrrega: escalar (més rèpliques / més recursos).

**Escalat:** si en 30 minuts no s'ha mitigat, avisar el responsable del servei.

## Errors 5xx

**Símptoma:** més de l'1 % de respostes 5xx durant 3 minuts.
**Primera acció:** `docker logs --tail 200` i buscar `Error no controlat`.
**Causa més freqüent:** la base de dades no accessible (volum sense permisos després d'un desplegament).

  1. Marcar els desplegaments al panell

La pregunta més freqüent en un incident és: «hem tocat res?». Un panell que superposa els desplegaments sobre les mètriques la respon d'un cop d'ull.

Ja tenim una marca implícita —changes(mini_reservalia_actiu_segons[2m]), perquè el comptador es reinicia en arrencar el procés—, però és indirecta i no porta metadades. Enviarem una anotació explícita des del cd.yml.

Afegeix al final del job produccio:

      - name: Anotar el desplegament a Grafana
        if: always() && vars.GRAFANA_URL != ''
        continue-on-error: true    # una anotacio fallida NO ha de tombar el desplegament
        env:
          GRAFANA_URL: ${{ vars.GRAFANA_URL }}
          GRAFANA_TOKEN: ${{ secrets.GRAFANA_TOKEN }}
        run: |
          set -Eeuo pipefail
          ARA_MS=$(( $(date +%s) * 1000 ))
          ESTAT="${{ job.status }}"
          COLOR=$([ "$ESTAT" = "success" ] && echo "verd" || echo "vermell")

          curl -sS -X POST "${GRAFANA_URL}/api/annotations" \
            -H "Authorization: Bearer ${GRAFANA_TOKEN}" \
            -H 'Content-Type: application/json' \
            -d @- <<JSON
          {
            "dashboardUID": "mini-reservalia",
            "time": ${ARA_MS},
            "timeEnd": ${ARA_MS},
            "tags": ["desplegament", "produccio", "${ESTAT}", "${COLOR}"],
            "text": "<b>Desplegament ${ESTAT}</b><br/>Commit: ${{ needs.preparar.outputs.commit }}<br/>Digest: <code>${{ needs.preparar.outputs.digest }}</code><br/>Per: @${{ github.actor }}<br/><a href='${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}'>Veure l execucio</a>"
          }
          JSON
          echo "Anotacio enviada a Grafana."

Configura'l:

# A 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"

Afegeix la capa d'anotacions al dashboard:

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

Què has de veure: una línia vertical amb un triangle a la base del gràfic en el moment exacte de cada desplegament; en passar-hi el ratolí, el commit, el digest, qui l'ha aprovat i un enllaç a l'execució.

I ara el que ensenya de debò. Torna a desplegar la versió amb RETARD_ARTIFICIAL_MS=500 i mira el panell: hi veuràs la línia vertical del desplegament i, exactament a partir d'ella, la corba del p95 pujant. Aquesta imatge —el moment del canvi i el moment de l'empitjorament coincidint— és la que converteix una discussió de mitja hora («no crec que sigui nostre, deu ser la xarxa») en una decisió de trenta segons. És probablement la millor relació valor/esforç de tota aquesta lliçó: vint línies de curl al cd.yml.

Equivalent real a Reservalia. El cd.yml de Reservalia envia l'anotació a Grafana Cloud i, a més, un esdeveniment de desplegament a l'eina d'APM, de manera que les traces queden etiquetades amb la versió. Amb això, «des de quan va lent?» es respon filtrant per versió en lloc de per hora.

  1. Tancar el bucle: les quatre DORA del teu repositori

Fins aquí has mesurat el sistema. Ara mesurarem el procés, que és el que la 01-05 va introduir amb la línia base de Reservalia. Les quatre mètriques i com es calculen des de l'API de GitHub:

Mètrica DORA Definició Font a GitHub
Freqüència de desplegament Desplegaments a producció per unitat de temps Deployments amb environment=produccio i estat success
Lead time for changes Del commit a producció commit.author.datedeployment_status.created_at
Change failure rate % de desplegaments que requereixen remei Desplegaments fallits + execucions de rollback.yml ÷ total
Time to restore De la fallada a la recuperació Del desplegament fallit al següent desplegament amb èxit

scripts/dora.js:

#!/usr/bin/env node
// scripts/dora.js
// Calcula les quatre metriques DORA d AQUEST repositori amb l API de GitHub.
//
// Us:
//   GH_TOKEN=... REPO=owner/repo DIES=30 node scripts/dora.js
//
// S recolza en els Deployments que GitHub crea automaticament quan un job
// fa servir `environment:`. Per aixo el `cd.yml` de la 07-03 els genera sense
// escriure una linia extra: fer servir Environments et regala la traçabilitat.

const TOKEN = process.env.GH_TOKEN ?? process.env.GITHUB_TOKEN;
const REPO = process.env.REPO ?? process.env.GITHUB_REPOSITORY;
const DIES = Number(process.env.DIES ?? 30);
const ENTORN = process.env.ENTORN_PRODUCCIO ?? 'produccio';

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

const DES_DE = new Date(Date.now() - DIES * 24 * 3600 * 1000);

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

// ---------------------------------------------------------------------------
// 1. Recopilar els desplegaments de l entorn de produccio
// ---------------------------------------------------------------------------
const desplegaments = [];
for (let pagina = 1; pagina <= 5; pagina++) {
  const lot = await api(`/repos/${REPO}/deployments?environment=${ENTORN}&per_page=100&page=${pagina}`);
  if (lot.length === 0) break;
  for (const d of lot) {
    if (new Date(d.created_at) < DES_DE) continue;
    const estats = await api(`/repos/${REPO}/deployments/${d.id}/statuses?per_page=100`);
    const finals = estats.filter((e) => ['success', 'failure', 'error'].includes(e.state));
    if (finals.length === 0) continue;
    const final = finals[0]; // l API els retorna del mes recent al mes antic
    desplegaments.push({
      id: d.id,
      sha: d.sha,
      creat: new Date(d.created_at),
      acabat: new Date(final.created_at),
      exit: final.state === 'success',
    });
  }
  if (lot.length < 100) break;
}
desplegaments.sort((a, b) => a.acabat - b.acabat);

if (desplegaments.length === 0) {
  console.log(`Sense desplegaments a "${ENTORN}" en els ultims ${DIES} dies.`);
  process.exit(0);
}

// ---------------------------------------------------------------------------
// 2. Mètrica 1: freqüència de desplegament
// ---------------------------------------------------------------------------
const reeixits = desplegaments.filter((d) => d.exit);
const perSetmana = (reeixits.length / DIES) * 7;

// ---------------------------------------------------------------------------
// 3. Mètrica 2: lead time (commit -> produccio)
// ---------------------------------------------------------------------------
const leadTimes = [];
for (const d of reeixits) {
  try {
    const commit = await api(`/repos/${REPO}/commits/${d.sha}`);
    const dataCommit = new Date(commit.commit.author.date);
    const hores = (d.acabat - dataCommit) / 3_600_000;
    if (hores >= 0 && hores < 24 * 90) leadTimes.push(hores);
  } catch {
    /* commit esborrat per un force-push o similar: s 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 desplegament "falla" si el seu estat es failure/error O si va anar
//    seguit d un rollback. Aixo segon es el que la majoria oblida comptar, i es
//    justament el cas mes greu: el desplegament "va funcionar" pero va trencar alguna cosa.
// ---------------------------------------------------------------------------
const runs = await api(
  `/repos/${REPO}/actions/workflows/rollback.yml/runs?per_page=100&created=%3E${DES_DE.toISOString().slice(0, 10)}`,
).catch(() => ({ workflow_runs: [] }));
const rollbacks = (runs.workflow_runs ?? []).filter((r) => r.conclusion === 'success');

const fallits = desplegaments.filter((d) => !d.exit).length;
const cfr = ((fallits + rollbacks.length) / desplegaments.length) * 100;

// ---------------------------------------------------------------------------
// 5. Mètrica 4: time to restore
//    Del primer desplegament fallit al seguent amb exit.
// ---------------------------------------------------------------------------
const restauracions = [];
for (let i = 0; i < desplegaments.length; i++) {
  if (desplegaments[i].exit) continue;
  const seguent = desplegaments.slice(i + 1).find((d) => d.exit);
  if (seguent) restauracions.push((seguent.acabat - desplegaments[i].acabat) / 60_000);
}
for (const r of rollbacks) {
  const minuts = (new Date(r.updated_at) - new Date(r.created_at)) / 60_000;
  if (minuts > 0 && minuts < 24 * 60) restauracions.push(minuts);
}
const restauracioMediana = mediana(restauracions);

// ---------------------------------------------------------------------------
// 6. Classificació (llindars de l informe State of DevOps)
// ---------------------------------------------------------------------------
const nivellFrequencia = perSetmana >= 7 ? 'Elit' : perSetmana >= 1 ? 'Alt' : perSetmana >= 0.25 ? 'Mitja' : 'Baix';
const nivellLead = leadMediana < 24 ? 'Elit' : leadMediana < 168 ? 'Alt' : leadMediana < 720 ? 'Mitja' : 'Baix';
const nivellCfr = cfr <= 5 ? 'Elit' : cfr <= 10 ? 'Alt' : cfr <= 15 ? 'Mitja' : 'Baix';
const nivellRestore = restauracioMediana < 60 ? 'Elit' : restauracioMediana < 1440 ? 'Alt' : 'Mitja';

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

const informe = `## 📊 Mètriques DORA · últims ${DIES} dies

| Mètrica | Valor | Nivell |
|---|---|---|
| **Freqüència de desplegament** | ${perSetmana.toFixed(1)} / setmana | ${nivellFrequencia} |
| **Lead time (mediana)** | ${fmtHores(leadMediana)} | ${nivellLead} |
| **Change failure rate** | ${cfr.toFixed(1)} % | ${nivellCfr} |
| **Time to restore (mediana)** | ${restauracioMediana.toFixed(0)} min | ${nivellRestore} |

<details><summary>Detall del càlcul</summary>

- Desplegaments a \`${ENTORN}\` analitzats: **${desplegaments.length}** (${reeixits.length} amb èxit, ${fallits} fallits)
- Execucions de rollback amb èxit: **${rollbacks.length}**
- Mostres de lead time: ${leadTimes.length}
- Mostres de restauració: ${restauracions.length}
- Finestra: des del ${DES_DE.toISOString().slice(0, 10)}

</details>

> Les quatre es calculen des de l'API de GitHub. La freqüència i el lead time
> mesuren **rapidesa**; el CFR i el time to restore mesuren **estabilitat**. Millorar
> les primeres empitjorant les segones no és millorar: les quatre es llegeixen juntes.
`;

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 flux de treball programat, .github/workflows/dora.yml:

name: Metriques DORA

on:
  schedule:
    # Dilluns a les 08:00 UTC. Compte: `schedule` fa servir SEMPRE UTC i GitHub
    # pot endarrerir-ho uns quants minuts si hi ha cua. No ho facis servir per a res
    # que depengui de la puntualitat.
    - cron: '0 8 * * 1'
  workflow_dispatch:
    inputs:
      dies:
        description: 'Finestra d analisi en dies'
        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 les quatre metriques
        env:
          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
          REPO: ${{ github.repository }}
          DIES: ${{ inputs.dies || '30' }}
        run: node scripts/dora.js | tee informe-dora.md

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

      # Opcional pero molt recomanable: obrir/actualitzar una issue fixada
      # perque les metriques es vegin sense haver-les de buscar.
      - name: Publicar en una 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ètriques DORA (informe setmanal)" \
              --label dora --body-file informe-dora.md
          fi

Executa'l a mà:

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

Què has de veure al resum de l'execució:

## 📊 Mètriques DORA · últims 30 dies

| Mètrica | Valor | Nivell |
|---|---|---|
| Freqüència de desplegament | 3.5 / setmana | Alt |
| Lead time (mediana) | 42 min | Elit |
| Change failure rate | 12.5 % | Mitja |
| Time to restore (mediana) | 2 min | Elit |

Un CFR alt en aquest laboratori és normal i esperable: has provocat fallades expressament. I aquí hi ha la lliçó: un número aïllat no significa res; el que significa alguna cosa és la tendència. Reservalia va passar d'1,5 desplegaments/setmana, 68 h de lead time, 6,5 % de CFR i 68 minuts de restauració a 12/setmana, 3,5 h, 3,8 % i 9 minuts. No perquè algú decidís «millorar les DORA», sinó perquè cada millora concreta del pipeline —memòria cau, paral·lelització, artefacte immutable, rollback per digest, portes automàtiques— va moure alguna de les quatre. Les mètriques són el termòmetre, no la medicina.

  1. Rollback automàtic per mètriques

El tancament del bucle. El smoke test valida trenta segons; ara vigilarem deu minuts i revertirem sols si alguna cosa es degrada.

scripts/vigilar-desplegament.sh:

#!/usr/bin/env bash
# Vigila les metriques del servei DESPRES de desplegar.
# Surt 0 si tot va be; surt 1 si cal revertir.
#
# Us:
#   BASE=http://localhost:3002 FINESTRA_MIN=10 ./scripts/vigilar-desplegament.sh

set -Eeuo pipefail

BASE="${BASE:?Falta BASE}"
FINESTRA_MIN="${FINESTRA_MIN:-10}"
LLINDAR_ERROR_PCT="${LLINDAR_ERROR_PCT:-2.0}"
LLINDAR_P95_SEG="${LLINDAR_P95_SEG:-0.5}"
INTERVAL_SEG="${INTERVAL_SEG:-30}"
# Quantes comprovacions consecutives dolentes calen per revertir.
# Amb 1 de sola, un pic transitori provocaria un rollback innecessari:
# el rollback tambe es un canvi, i els canvis innecessaris tenen cost.
DOLENTES_SEGUIDES_MAX="${DOLENTES_SEGUIDES_MAX:-3}"

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

# Llegeix una metrica de l endpoint /metriques per nom exacte de serie.
llegir_metrica() {
  local patro="$1"
  curl -fsS --max-time 5 "$BASE/metriques" 2>/dev/null \
    | grep -E "^${patro}" | awk '{s+=$NF} END {print (NR?s:0)}'
}

log "Vigilant $BASE durant $FINESTRA_MIN min"
log "Llindars: errors 5xx < ${LLINDAR_ERROR_PCT}% · p95 < ${LLINDAR_P95_SEG}s"

# Linia base: els comptadors son acumulatius des de l arrencada, aixi que
# mesurem INCREMENTS respecte a l inici de la vigilancia.
BASE_TOTAL=$(llegir_metrica 'http_peticions_total\{')
BASE_5XX=$(llegir_metrica 'http_peticions_total\{codi="5')
BASE_SUM=$(llegir_metrica 'http_duracio_segons_sum')
BASE_CNT=$(llegir_metrica 'http_duracio_segons_count')
log "Linia base: total=$BASE_TOTAL 5xx=$BASE_5XX"

FI=$(( $(date +%s) + FINESTRA_MIN * 60 ))
DOLENTES=0
CICLE=0

while [ "$(date +%s)" -lt "$FI" ]; do
  sleep "$INTERVAL_SEG"
  CICLE=$((CICLE + 1))

  if ! curl -fsS --max-time 5 "$BASE/salut" >/dev/null 2>&1; then
    log "CRITIC: /salut no respon. Revertir immediatament."
    exit 1
  fi

  TOTAL=$(llegir_metrica 'http_peticions_total\{')
  CINCXX=$(llegir_metrica 'http_peticions_total\{codi="5')
  SUM=$(llegir_metrica 'http_duracio_segons_sum')
  CNT=$(llegir_metrica 'http_duracio_segons_count')

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

  # Sense transit no es pot concloure res. No revertir per falta de dades:
  # "no ho se" no es el mateix que "va malament".
  if awk "BEGIN{exit !($D_TOTAL < 5)}"; then
    log "Cicle $CICLE: nomes $D_TOTAL peticions; mostra insuficient, s omet."
    continue
  fi

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

  log "Cicle $CICLE: peticions=$D_TOTAL errors5xx=${PCT_ERROR}% latencia_mitjana=${LAT_MITJANA}s"

  DOLENTA=0
  awk "BEGIN{exit !($PCT_ERROR > $LLINDAR_ERROR_PCT)}" && { log "  ⚠ errors per sobre del llindar"; DOLENTA=1; }
  awk "BEGIN{exit !($LAT_MITJANA > $LLINDAR_P95_SEG)}"  && { log "  ⚠ latencia per sobre del llindar"; DOLENTA=1; }

  if [ "$DOLENTA" -eq 1 ]; then
    DOLENTES=$((DOLENTES + 1))
    log "  Comprovacions dolentes consecutives: $DOLENTES/$DOLENTES_SEGUIDES_MAX"
    if [ "$DOLENTES" -ge "$DOLENTES_SEGUIDES_MAX" ]; then
      log "DECISIO: revertir. $DOLENTES comprovacions consecutives fora de llindar."
      exit 1
    fi
  else
    [ "$DOLENTES" -gt 0 ] && log "  Recuperat; comptador de dolentes a zero."
    DOLENTES=0
  fi
done

log "Vigilancia completada sense incidencies. Desplegament estable."
exit 0

I el job al cd.yml, després del desplegament a producció:

  vigilar:
    name: Vigilancia posterior al desplegament
    runs-on: ubuntu-latest
    needs: [preparar, produccio]
    timeout-minutes: 20
    permissions:
      contents: read
      actions: write        # necessari per llancar el flux de treball de rollback
    steps:
      - uses: actions/checkout@v4

      - name: Generar transit sintetic de fons
        run: |
          # Sense transit no hi ha metriques. En un sistema real aixo sobra:
          # el transit el generen els usuaris.
          (for i in $(seq 1 600); do
             curl -s "http://localhost:3002/api/forats?data=2026-03-02" > /dev/null 2>&1 || true
             sleep 1
           done) &
          echo "generador=$!" >> "$GITHUB_ENV"

      - name: Vigilar 10 minuts
        id: vigilancia
        continue-on-error: true    # volem DECIDIR segons el resultat, no avortar
        env:
          BASE: http://localhost:3002
          FINESTRA_MIN: '10'
          LLINDAR_ERROR_PCT: '2.0'
          LLINDAR_P95_SEG: '0.5'
        run: ./scripts/vigilar-desplegament.sh

      - name: Rollback automatic si la vigilancia ha fallat
        if: steps.vigilancia.outcome == 'failure'
        env:
          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
        run: |
          set -Eeuo pipefail
          ANTERIOR="${{ needs.produccio.outputs.digest_anterior }}"
          if [ -z "$ANTERIOR" ]; then
            echo "::error::La vigilancia ha fallat pero no hi ha digest anterior conegut. INTERVENCIO MANUAL."
            exit 1
          fi

          echo "::warning::Metriques degradades despres del desplegament. Revertint a $ANTERIOR"
          gh workflow run rollback.yml \
            -f entorn=produccio \
            -f digest="$ANTERIOR" \
            -f motiu="Rollback AUTOMATIC: metriques fora de llindar despres de desplegar ${{ needs.preparar.outputs.digest }}"

          {
            echo "## 🔴 Rollback automatic disparat"
            echo ""
            echo "| Camp | Valor |"
            echo "|---|---|"
            echo "| Digest problematic | \`${{ needs.preparar.outputs.digest }}\` |"
            echo "| Revertit a | \`$ANTERIOR\` |"
            echo "| Motiu | Llindars d error o latencia superats durant 3 cicles |"
            echo ""
            echo "**Acció requerida:** escriure el post-mortem abans de reintentar."
          } >> "$GITHUB_STEP_SUMMARY"
          exit 1

      - name: Confirmar desplegament estable
        if: steps.vigilancia.outcome == 'success'
        run: echo "### ✅ Desplegament estable despres de 10 minuts de vigilancia" >> "$GITHUB_STEP_SUMMARY"

Perquè needs.produccio.outputs.digest_anterior existeixi, afegeix l'output al job produccio:

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

(El script desplegar.sh de la 07-03 ja l'escriu a $GITHUB_OUTPUT.)

Prova-ho de debò: desplega la versió amb RETARD_ARTIFICIAL_MS=800, que supera el llindar de 0,5 s.

Què has de veure:

[vigilancia 10:15:00] Vigilant http://localhost:3002 durant 10 min
[vigilancia 10:15:30] Cicle 1: peticions=29 errors5xx=0.00% latencia_mitjana=0.812s
[vigilancia 10:15:30]   ⚠ latencia per sobre del llindar
[vigilancia 10:15:30]   Comprovacions dolentes consecutives: 1/3
[vigilancia 10:16:00] Cicle 2: ... latencia_mitjana=0.809s
[vigilancia 10:16:00]   Comprovacions dolentes consecutives: 2/3
[vigilancia 10:16:30] Cicle 3: ... latencia_mitjana=0.815s
[vigilancia 10:16:30] DECISIO: revertir. 3 comprovacions consecutives fora de llindar.

I tot seguit, el rollback.yml arrencant sol. Temps total des del desplegament dolent fins al servei restaurat: uns 3 minuts, sense que cap persona hagi fet res. Això és el que mou el «time to restore» de 68 minuts a 9 en la línia de Reservalia.

Les tres decisions de disseny que fan que això sigui segur i no un generador de caos:

Decisió Per què
3 cicles dolents consecutius, no un Un pic transitori no ha de provocar un rollback. El rollback també és un canvi
Mostra mínima (5 peticions) Sense trànsit, «0 errors de 0 peticions» no és informació. No revertir per falta de dades
S'avorta si no hi ha digest anterior Un rollback cap enlloc és pitjor que el problema. Més val escalar-ho a una persona

  1. Verificació final

# Comprovació Com Esperat
1 /metriques respon en format Prometheus curl localhost:3002/metriques Línies # HELP, # TYPE, sèries
2 Els cubells són acumulatius npm test La prova de l'histograma en verd
3 No hi ha explosió de cardinalitat curl -s .../metriques | grep -c '^http_peticions_total{' Menys de 20 sèries
4 Prometheus raspa els dos entorns localhost:9090/targets Dos objectius UP
5 El dashboard s'aprovisiona sol Grafana sense importar res Panell present
6 El panell no es pot editar a la UI Intentar desar Bloquejat
7 L'SLO està documentat amb la seva aritmètica observabilitat/SLO.md Els quatre elements + pressupost
8 Les regles són vàlides promtool check rules SUCCESS: 4 rules found
9 L'alerta passa per PENDING i arriba a FIRING Retard artificial + 6 min Els tres estats observats
10 L'alerta es recupera sola Treure el retard Torna a Inactive
11 Els desplegaments s'anoten Panell després d'un desplegament Línia vertical amb metadades
12 Les DORA es calculen gh workflow run dora.yml Taula amb les quatre
13 El rollback automàtic es dispara Desplegar amb retard de 800 ms Rollback llançat sol en ~3 min
14 No reverteix per falta de dades Vigilar sense trànsit «mostra insuficient, s'omet»

Errors Comuns i Consells

Símptoma: histogram_quantile retorna NaN o no dibuixa res. Causa: has perdut l'etiqueta le a l'agregació. sum(rate(...bucket[5m])) sense by (le) destrueix la informació de l'histograma. Arranjament: sempre sum by (le, <les altres>) (rate(..._bucket[5m])). És l'error número u de PromQL.

Símptoma: Prometheus mostra l'objectiu DOWN amb connection refused. Causa: des de dins del contenidor de Prometheus, localhost és el mateix Prometheus, no la teva màquina. Arranjament: host.docker.internal amb l'extra_hosts: host-gateway que ja és al compose. Verifica-ho: docker exec prometheus wget -qO- http://host.docker.internal:3002/metriques | head -3.

Símptoma: la memòria de Prometheus creix sense control i les consultes triguen segons. Causa: explosió de cardinalitat. Alguna etiqueta té valors il·limitats: una URL completa, un id d'usuari, una marca de temps. Arranjament: la funció rutaNormalitzada de l'apartat 2. Regla: el nombre de valors diferents d'una etiqueta ha de ser fitat i petit. Diagnòstic: topk(10, count by (__name__)({__name__=~".+"})).

Símptoma: l'alerta es dispara i s'apaga sola cada pocs minuts («flapping»). Causa: el for és massa curt per a la volatilitat de la mètrica, o el llindar és just al valor habitual. Arranjament: puja el for o allunya el llindar. Regla pràctica: el llindar ha d'estar almenys un 50 % per sobre del percentil 99 d'operació normal. Una alerta que es dispara cada dia deixa de llegir-se en una setmana.

Símptoma: els comptadors es reinicien a zero de cop i el rate() fa un pic estrany. Causa: el procés s'ha reiniciat (un desplegament). Les mètriques viuen en memòria. Arranjament: cap de necessari. El rate() de Prometheus detecta els reinicis de comptador i els compensa. Per això rate() sobre un counter és correcte i restar valors a mà no ho és.

Símptoma: el job schedule de DORA no s'executa a la seva hora, o deixa d'executar-se. Causes: (1) cron és UTC, sempre; (2) GitHub endarrereix els schedule quan hi ha càrrega, fins a força minuts; (3) GitHub desactiva els fluxos de treball programats en repositoris sense activitat durant 60 dies, i t'ho avisa per correu una vegada. Arranjament: no depenguis de la puntualitat; mantén el workflow_dispatch per poder llançar-lo a mà.

Símptoma: el rollback automàtic es dispara en un desplegament perfectament bo. Causa: el llindar s'ha avaluat durant l'arrencada, amb les memòries cau fredes i les primeres peticions lentes. Arranjament: afegeix un període de gràcia abans de començar a comptar (sleep 60 inicial), o descarta el primer cicle. És l'equivalent del start-period del HEALTHCHECK de Docker.

Consell — instrumenta el flux de negoci, no només l'HTTP. Les senyals d'or et diuen si el sistema funciona. Un comptador cites_creades_total et diu si el producte funciona. Un desplegament pot deixar tots els HTTP en 200 i les cites creades a zero, i aquesta és la caiguda que de debò costa diners. És l'exercici 2.

Consell — la mètrica que gairebé ningú no posa i sempre fa falta. mini_reservalia_info{versio="..."} no mesura res, però respon a l'instant a «quina versió hi ha desplegada?» des del mateix lloc on veus el problema. Costa tres línies.

Exercicis

Exercici 1: un SLO de disponibilitat, a més del de latència

Defineix un segon SLO —99,9 % de peticions a /api/forats sense error 5xx en 30 dies—, calcula'n el pressupost d'error amb l'aritmètica explícita, escriu la consulta de l'SLI i afegeix una alerta de burn rate amb dues finestres (una de ràpida i una de lenta) per evitar falsos positius.

Exercici 2: mètriques de negoci

Afegeix mètriques que mesurin el producte i no la infraestructura: cites creades, cites rebutjades per validació, i distribució de la durada sol·licitada. Afegeix una alerta per a «fa 30 minuts que no es crea cap cita en horari comercial».

Exercici 3: històric de DORA amb tendència

Fes que l'informe DORA desi el seu històric i mostri la variació respecte a la setmana anterior, amb fletxes de tendència. Un número aïllat no serveix; una tendència sí.

Solucions

Solució 1.

## SLO 2: disponibilitat de /api/forats

| Camp | Valor |
|---|---|
| SLI | Proporció de peticions a `/api/forats` sense codi 5xx |
| Objectiu | 99,9 % |
| Finestra | 30 dies |

### Aritmètica del pressupost

    Trànsit:  20 pet/min
    Finestra: 30 dies

    Peticions = 20 × 60 × 24 × 30 = 864.000

    Pressupost = (100 % − 99,9 %) = 0,1 %
               = 0,001 × 864.000 = 864 peticions fallides

    En temps (caiguda total):
               864 ÷ 20 pet/min = 43,2 minuts cada 30 dies
               ≈ 1,44 minuts per dia

Nota: l'SLO de latència (99,5 %) permet 1.008 peticions lentes cada 7 dies;
el de disponibilitat permet 864 de fallides cada 30 dies. Són pressupostos
INDEPENDENTS i el més restrictiu mana: esgotar-ne qualsevol dels dos
dispara la congelació.
# SLI de disponibilitat
1 - (
  sum(rate(http_peticions_total{ruta="/api/forats", codi=~"5.."}[30d]))
  /
  sum(rate(http_peticions_total{ruta="/api/forats"}[30d]))
)

Alerta de burn rate amb dues finestres:

      # La finestra LLARGA (1 h) detecta el problema sostingut.
      # La finestra CURTA (5 m) confirma que CONTINUA passant ARA.
      # Exigir totes dues elimina les alertes per un incident ja resolt,
      # que es la causa numero u de desconfianca en les alertes.
      - alert: PressupostDisponibilitatCremantseRapid
        expr: |
          (
            sum by (entorn) (rate(http_peticions_total{ruta="/api/forats",codi=~"5.."}[1h]))
            / sum by (entorn) (rate(http_peticions_total{ruta="/api/forats"}[1h]))
          ) > (14.4 * 0.001)
          and
          (
            sum by (entorn) (rate(http_peticions_total{ruta="/api/forats",codi=~"5.."}[5m]))
            / sum by (entorn) (rate(http_peticions_total{ruta="/api/forats"}[5m]))
          ) > (14.4 * 0.001)
        for: 2m
        labels: { severitat: critica, tipus: burn-rate-rapid }
        annotations:
          resum: 'Pressupost de disponibilitat cremant-se 14,4x mes rapid del sostenible'
          descripcio: 'A aquest ritme, el pressupost de 30 dies s esgota en ~2 dies.'

      - alert: PressupostDisponibilitatCremantseLent
        expr: |
          (
            sum by (entorn) (rate(http_peticions_total{ruta="/api/forats",codi=~"5.."}[6h]))
            / sum by (entorn) (rate(http_peticions_total{ruta="/api/forats"}[6h]))
          ) > (6 * 0.001)
          and
          (
            sum by (entorn) (rate(http_peticions_total{ruta="/api/forats",codi=~"5.."}[30m]))
            / sum by (entorn) (rate(http_peticions_total{ruta="/api/forats"}[30m]))
          ) > (6 * 0.001)
        for: 15m
        labels: { severitat: avis, tipus: burn-rate-lent }

Els multiplicadors de l'SRE Workbook: 14,4× consumeix el pressupost sencer en 2 dies (alerta que desperta); el consumeix en 5 dies (alerta que obre un tiquet). El patró de dues finestres és el que fa que l'alerta s'apagui sola quan l'incident es resol, en lloc de continuar sonant per la contaminació de la finestra llarga.

Solució 2.

// A src/servidor.js, dins del gestor de POST /api/cites:
      if (req.method === 'POST' && url.pathname === '/api/cites') {
        const cos = await llegirCos(req);
        const duracioSolicitada = cos.inici && cos.fi
          ? aMinuts(cos.fi) - aMinuts(cos.inici)
          : 0;
        try {
          const cita = repositori.crearCita(cos);
          metriques.incrementar('cites_creades_total', { entorn: ENTORN });
          // Cubells en MINUTS: 15, 30, 45, 60, 90, 120.
          metriques.observar('cita_duracio_minuts', {}, duracioSolicitada);
          return respondreJson(res, 201, cita);
        } catch (error) {
          metriques.incrementar('cites_rebutjades_total', {
            // L etiqueta es el TIPUS d error, no el missatge: els missatges
            // son text lliure i farien explotar la cardinalitat.
            motiu: error instanceof RangeError ? 'rang_invalid' : 'format_invalid',
          });
          throw error;
        }
      }
      - alert: SenseCitesCreades
        # `hour()` retorna l hora UTC. L `and` amb els rangs horaris i
        # el dia de la setmana evita que l alerta soni un diumenge a la nit,
        # quan zero cites es el normal.
        expr: |
          (
            sum(increase(cites_creades_total{entorn="produccio"}[30m])) == 0
            or
            absent(cites_creades_total{entorn="produccio"})
          )
          and on() (hour() >= 8 and hour() < 18)
          and on() (day_of_week() > 0 and day_of_week() < 6)
        for: 10m
        labels: { severitat: critica, tipus: negoci }
        annotations:
          resum: 'Cap cita creada en 30 minuts, en horari comercial'
          descripcio: >-
            Els HTTP poden estar en 200 i el producte estar trencat igualment.
            Comprovar el flux complet de reserva abans que la infraestructura.

Aquesta última és, de llarg, l'alerta més valuosa del fitxer. Una regressió que trenca el formulari de reserva deixa tots els endpoints retornant 200 —ningú no arriba a cridar-los— i cap senyal d'or no s'immuta. L'única mètrica que se n'assabenta és la de negoci.

Solució 3.

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

const HISTORIC = 'observabilitat/historic-dora.json';

const actual = {
  data: new Date().toISOString().slice(0, 10),
  finestraDies: DIES,
  frequenciaSetmanal: Number(perSetmana.toFixed(2)),
  leadTimeHores: Number(leadMediana.toFixed(2)),
  cfrPct: Number(cfr.toFixed(2)),
  restauracioMin: Number(restauracioMediana.toFixed(1)),
};

let historic = [];
try {
  historic = JSON.parse(await readFile(HISTORIC, 'utf8'));
} catch { /* primera execucio */ }

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

/**
 * Fletxa de tendencia. `millorSiPuja` distingeix les metriques de rapidesa
 * (mes es millor) de les d estabilitat (menys es millor): sense aquest parametre,
 * un CFR que puja sortiria amb fletxa verda.
 */
function tendencia(actualVal, anteriorVal, millorSiPuja) {
  if (anteriorVal === undefined) return '—';
  const delta = actualVal - anteriorVal;
  if (Math.abs(delta) < 0.001) return '→ igual';
  const millora = millorSiPuja ? delta > 0 : delta < 0;
  const signe = delta > 0 ? '+' : '';
  return `${millora ? '🟢 ▲' : '🔴 ▼'} ${signe}${delta.toFixed(1)}`;
}

const taula = `
### Tendència respecte a la mesura anterior${anterior ? ` (${anterior.data})` : ''}

| Mètrica | Anterior | Actual | Tendència |
|---|---|---|---|
| Freqüència (/setmana) | ${anterior?.frequenciaSetmanal ?? '—'} | ${actual.frequenciaSetmanal} | ${tendencia(actual.frequenciaSetmanal, anterior?.frequenciaSetmanal, true)} |
| Lead time (h) | ${anterior?.leadTimeHores ?? '—'} | ${actual.leadTimeHores} | ${tendencia(actual.leadTimeHores, anterior?.leadTimeHores, false)} |
| CFR (%) | ${anterior?.cfrPct ?? '—'} | ${actual.cfrPct} | ${tendencia(actual.cfrPct, anterior?.cfrPct, false)} |
| Restauració (min) | ${anterior?.restauracioMin ?? '—'} | ${actual.restauracioMin} | ${tendencia(actual.restauracioMin, anterior?.restauracioMin, false)} |

<details><summary>Sèrie històrica (${historic.length} mesures)</summary>

\`\`\`
${historic.slice(-12).map((h) =>
  `${h.data}  freq=${String(h.frequenciaSetmanal).padStart(5)}/set  lead=${String(h.leadTimeHores).padStart(6)}h  cfr=${String(h.cfrPct).padStart(5)}%  mttr=${String(h.restauracioMin).padStart(5)}min`,
).join('\n')}
\`\`\`

</details>
`;

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

I al flux de treball, perquè l'històric persisteixi:

      - name: Commitejar l historic
        run: |
          git config user.name  'github-actions[bot]'
          git config user.email 'github-actions[bot]@users.noreply.github.com'
          git add observabilitat/historic-dora.json
          # `|| exit 0`: si no hi ha canvis, `git commit` surt amb 1 i no es una fallada.
          git diff --staged --quiet || git commit -m "chore: mètriques DORA $(date -u +%Y-%m-%d)"
          git push

Necessita permissions: contents: write al job i, si main està protegida, un bypass del ruleset per a github-actions[bot] o una branca dedicada.

Desar l'històric al mateix repositori té una virtut que compensa la seva tosquedat: la dada viatja amb el codi, es revisa als PR i no depèn de cap servei extern que algú hagi de pagar. Per a un equip petit és més que suficient.

Repte opcional

Substitueix la vigilància basada en /metriques per una consulta a Prometheus (/api/v1/query amb PromQL), que és el que faria un sistema real: et permet fer servir percentils reals en lloc de la mitjana, comparar amb la setmana anterior a la mateixa hora, i correlacionar diverses mètriques en una sola expressió. La consulta seria una cosa com ara histogram_quantile(0.95, sum by (le) (rate(http_duracio_segons_bucket{entorn="produccio"}[5m]))) i el criteri de rollback, comparar aquest valor amb el d'abans del desplegament en lloc de amb un llindar absolut. És la diferència entre «va lent» i «va més lent que abans del teu canvi», que és la pregunta correcta.

Què has construït

  • Un registre de mètriques propi amb comptadors, gauges i histogrames acumulatius, en format Prometheus, sense dependències, amb protecció contra l'explosió de cardinalitat.
  • Un middleware d'instrumentació que mesura la latència i classifica els errors de client davant dels errors de servidor.
  • Una pila d'observabilitat amb docker compose, aprovisionada com a codi: Prometheus amb dos objectius etiquetats per entorn i Grafana amb datasource i dashboard versionats.
  • Un panell com a codi amb els tres percentils, el llindar de l'SLO dibuixat i una variable d'entorn.
  • Un SLO documentat amb els quatre elements, el seu pressupost d'error calculat amb aritmètica explícita i una política de decisió per trams.
  • Quatre regles d'alerta per símptoma amb for, runbook i burn rate, validades amb promtool, i l'experiència de veure'n una passar per PENDING fins a FIRING.
  • Anotacions de desplegament al panell, que fan visible la correlació entre canvi i degradació.
  • Un job programat que calcula les quatre mètriques DORA del teu propi repositori i les publica.
  • Un rollback automàtic per mètriques amb mostra mínima, cicles consecutius i escalat a una persona quan no pot decidir.

Conclusió

El bucle està tancat en les dues direccions. Cap a dins: el sistema et diu com està, amb un objectiu numèric, un pressupost que es consumeix i alertes que sonen pel que els usuaris noten. Cap al pipeline: el pipeline es mesura a si mateix, i ara pots respondre amb números a «això està funcionant?». I les dues direccions es troben al rollback automàtic, on una mètrica degradada mou una palanca del pipeline sense que ningú miri.

Aquest és també el punt en què convé parar i adonar-se del que has construït, perquè té un problema. El teu pipeline ara pot desplegar codi en producció i revertir-lo, sol, sense intervenció humana. Té credencials de registre. Té un token capaç de llançar fluxos de treball. Executa accions de tercers que apunten a etiquetes mòbils que els seus autors poden moure. Construeix imatges que executen processos amb dependències que ningú no ha auditat. I aquell GRAFANA_TOKEN que has posat fa una estona és una credencial de vida llarga desada en una variable que diversos jobs poden llegir.

Dit d'una altra manera: has construït un sistema molt capaç i molt permissiu, i encara no l'has mirat amb ulls d'atacant.

A la 07-05 comencem per aquí: una auditoria del pipeline que tu mateix has construït, amb la llista de tot el que està malament, i la seva correcció pas a pas. Aplicaràs permissions de privilegi mínim i veuràs com es llegeix l'error quan en falta un; fixaràs les accions per SHA i n'automatitzaràs l'actualització; afegiràs Gitleaks i cometràs un secret de prova expressament per executar el procediment complet de resposta —rotar primer, netejar l'historial després, i entendre per què en aquest ordre—; implementaràs una política de severitats amb jq sobre npm audit --json; activaràs CodeQL i hi introduiràs una injecció evident per veure-la detectada; escanejaràs la imatge amb Trivy amb excepcions que caduquen; generaràs un SBOM, signaràs la imatge amb Cosign i verificaràs la signatura abans de desplegar; comprovaràs l'emmascarament de secrets i per què no és una garantia; i veuràs l'atac concret que fa de pull_request_target el parany més perillós de GitHub Actions.

Curs de CI/CD: Integració i Desplegament Continu

Mòdul 1: Introducció al CI/CD

Mòdul 2: Integració Contínua (CI)

Mòdul 3: Desplegament Continu (CD)

Mòdul 4: Pràctiques Avançades de CI/CD

Mòdul 5: Implementació de CI/CD en Projectes Reals

Mòdul 6: Eines i Tecnologies

Mòdul 7: Exercicis Pràctics

Mòdul 8: Recursos Addicionals

© Copyright 2026. Tots els drets reservats