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
- Objectiu, requisits previs i punt de partida
- Instrumentar el servidor: comptadors i histogrames
- L'endpoint
/metriquesen format Prometheus - Les quatre senyals d'or sobre aquestes mètriques
- Prometheus i Grafana amb
docker compose - El panell com a codi
- L'SLO i el seu pressupost d'error, amb l'aritmètica
- Regles d'alerta per símptoma
- Disparar l'alerta expressament
- Marcar els desplegaments al panell
- Tancar el bucle: les quatre DORA del teu repositori
- Rollback automàtic per mètriques
- Verificació final
- Errors Comuns i Consells
- Exercicis
- Conclusió
- 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).
- 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);
};
}
- L'endpoint
/metriques en format Prometheus
/metriques en format PrometheusEmbolcalla 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 -30Què 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
...
- 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_totalseparatipus="client"detipus="servidor".
- Prometheus i Grafana amb
docker compose
docker composeobservabilitat/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: instanceobservabilitat/grafana/provisioning/datasources/prometheus.yml:
apiVersion: 1
datasources:
- name: Prometheus
type: prometheus
access: proxy
url: http://prometheus:9090
isDefault: true
uid: prometheus-miniobservabilitat/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/dashboardsAixeca la pila:
docker compose -f observabilitat/docker-compose.yml up -d
docker compose -f observabilitat/docker-compose.yml psQuè 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
doneQuè has de veure: una línia amb un valor proper a 5 peticions per segon mentre dura el bucle.
- 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.
- 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/foratshan 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.
- 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 foundAquest 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).
- 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
doneQuè 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:
I restaura la versió bona:
IMATGE=ghcr.io/EL_TEU_USUARI/mini-reservalia@sha256:BO \
ENTORN=staging PORT_AMFITRIO=3001 ./scripts/desplegar.shEscriu 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).
- 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.ymlde 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.
- 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.date → deployment_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
fiExecuta'l a mà:
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.
- 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 0I 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:
(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 |
- 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); 6× 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 pushNecessita 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 ambpromtool, i l'experiència de veure'n una passar perPENDINGfins aFIRING. - 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
- Conceptes Bàsics de CI/CD
- Beneficis del CI/CD
- Eines Populars de CI/CD
- El Projecte del Curs: l'Aplicació que Automatitzarem
- Mètriques DORA: Com es Mesura el Lliurament de Programari
Mòdul 2: Integració Contínua (CI)
- Introducció a la Integració Contínua
- Configuració d'un Entorn de CI
- Automatització de la Construcció
- Proves Automatitzades
- Qualitat de Codi i Anàlisi Estàtica
- Artefactes, Versionat i Promoció
- Integració amb el Control de Versions
Mòdul 3: Desplegament Continu (CD)
- Introducció al Desplegament Continu
- Automatització del Desplegament
- Infraestructura com a Codi i Entorns Reproduïbles
- Estratègies de Desplegament
- Feature Flags, Rollback i Recuperació davant Errors
- Monitoratge i Retroalimentació
Mòdul 4: Pràctiques Avançades de CI/CD
- Pipelines de CI/CD
- Gestió de Dependències
- Seguretat en CI/CD
- Escalabilitat i Rendiment
- Pipeline as Code: Plantilles, Reutilització i Proves del Pipeline
- Bases de Dades al Pipeline: Migracions Segures
Mòdul 5: Implementació de CI/CD en Projectes Reals
- Cas d'Estudi: Projecte Web
- Cas d'Estudi: Aplicació Mòbil
- Cas d'Estudi: Microserveis
- Cas d'Estudi: Modernitzar un Projecte Legacy
Mòdul 6: Eines i Tecnologies
- Jenkins
- GitLab CI/CD
- CircleCI
- Travis CI
- Docker i Kubernetes
- GitHub Actions a Fons
- Comparativa i Criteris per Triar Eina
Mòdul 7: Exercicis Pràctics
- Exercici 1: Configuració d'un Pipeline Bàsic
- Exercici 2: Integració de Proves Automatitzades
- Exercici 3: Desplegament en un Entorn de Producció
- Exercici 4: Monitoratge i Retroalimentació
- Exercici 5: Enfortir el Pipeline amb Seguretat i Secrets
- Projecte Final: Pipeline Complet d'Extrem a Extrem
