Són les 11:40 d'un dijous. Un client escriu: «he intentat pagar la comanda i m'ha donat un error». Res més. Amb el que tens avui a src/app.js —aquell console.log de la posició 5 que portem cinc lliçons prometent substituir— la investigació consisteix a buscar a mà entre milers de línies de text pla alguna cosa semblant a POST /v1/comandes/com_5001/pagament → 500 (312 ms), sense saber quin client era, sense poder filtrar per estat, sense veure l'error que ho va causar i sense manera de relacionar-ho amb la consulta SQL que va fallar.
Pitjor encara: la majoria de les vegades ni tan sols te n'assabentes. Els problemes es descobreixen perquè un client es queixa, no perquè el sistema avisi. I les decisions que portem prenent des de 04-01 —els límits de 04-04, la memòria cau de 04-06, el deute de disseny de 04-01— depenen de dades que ara mateix no estàs recollint.
Aquesta lliçó tanca el mòdul 4 amb la capa que fa governable tota la resta. Substituirem el console.log per pino amb logs estructurats i correlació per tracaId, instrumentarem l'API amb prom-client i exposarem /metriques, veurem les traces distribuïdes d'un POST /v1/comandes complet, distingirem liveness de readiness a /salut, i definirem què alertar i què mirar el dia del llançament.
Advertiment. Els logs són un dels llocs on més fàcilment es filtren dades personals i credencials, amb conseqüències legals sota el RGPD. La redacció de camps sensibles i la política de retenció d'aquesta lliçó són un punt de partida; qualsevol desplegament real requereix revisió de compliment normatiu i de seguretat. Totes les dades són fictícies.
Contingut
- Monitoratge enfront d'observabilitat
- Els tres pilars i quina pregunta respon cadascun
- Logs estructurats: per què JSON i no text
- pino i el registrador de la Botiga Aroma
- Què registrar sempre i què no registrar mai
- Redacció automàtica de camps sensibles
- El middleware
src/middleware/registre.js - Correlació d'extrem a extrem amb
Aroma-Traca-Id - Errors 5xx: què es registra enfront de què es retorna
- Mètriques: els quatre senyals d'or i el mètode RED
- Tipus de mètrica i per què l'histograma
- Instrumentar amb prom-client i exposar
/metriques - Cardinalitat: per què
com_5001rebenta el sistema - Traces distribuïdes: spans, context i
traceparent - Una traça de
POST /v1/comandes - Comprovacions de salut: liveness i readiness
- Alertes útils, SLO i pressupost d'error
- El tauler mínim del dia del llançament
- L'stack habitual
- Balanç del mòdul 4
- Monitoratge enfront d'observabilitat
| Monitoratge | Observabilitat | |
|---|---|---|
| Pregunta | Està bé allò que ja sé mirar? | Què està passant, sigui el que sigui? |
| Es basa en | Llindars sobre mètriques conegudes | Dades suficients per respondre preguntes noves |
| Fallades que detecta | Les que vas anticipar | També les que no |
| Exemple | «CPU > 80 %» | «Per què els pagaments de clients amb més de 3 línies triguen 2 s des de dimarts?» |
El monitoratge és necessari i no n'hi ha prou. Els sistemes moderns fallen de maneres que ningú no va preveure: una interacció entre el rate limiting i un client concret, una consulta que es degrada només amb certa distribució de dades, un webhook que s'encalla únicament quan RàpidEnviaments va lent.
La definició operativa que serveix per treballar: un sistema és observable si pots respondre preguntes noves sobre el seu comportament sense desplegar codi nou. Si per entendre un incident has d'afegir un console.log i esperar que torni a passar, el teu sistema no és observable.
- Els tres pilars i quina pregunta respon cadascun
| Pilar | Respon a | Granularitat | Cost | Exemple a Aroma |
|---|---|---|---|---|
| Logs | Què va passar exactament en aquest cas? | Un esdeveniment | Alt per volum | «El pagament de com_5001 va fallar: la passarel·la va retornar timeout» |
| Mètriques | Com va el sistema en conjunt? | Agregada | Molt baix | «El 2,3 % dels pagaments falla; fa una hora era el 0,1 %» |
| Traces | On se'n va anar el temps en aquesta petició? | Una petició, entre serveis | Mitjà (mostreig) | «Dels 312 ms, 280 els va consumir el gRPC d'inventari» |
El flux de treball real d'un incident els fa servir en aquest ordre:
- Una mètrica dispara l'alerta: la taxa d'error de
POST /v1/comandes/{id}/pagamentha pujat. - Una traça d'una petició fallida mostra on es trenca: la crida a la passarel·la.
- Un log amb el
tracaIdd'aquella traça dona el detall: el missatge exacte de l'error i elclientId.
Per això tots tres han d'estar correlacionats. Tres sistemes excel·lents sense un identificador comú valen molt menys que tres sistemes mediocres que comparteixen el tracaId. I aquell identificador ja el tenim: Aroma-Traca-Id, des de 03-02.
- Logs estructurats: per què JSON i no text
El que emet avui el nostre middleware:
El que hauria d'emetre:
{"nivell":"error","hora":"2026-08-15T11:40:22.318Z","tracaId":"trz_9f3a2b7c","metode":"POST","ruta":"/v1/comandes/:id/pagament","estat":500,"duracioMs":312,"clientId":"cli_842","clientOauth":"spa-botiga","codiError":"error_intern","causa":"timeout de la passarel·la"}| Text pla | JSON estructurat | |
|---|---|---|
| Cercar | grep i expressions regulars fràgils |
Consulta per camp |
| Filtrar per estat | Impossible sense analitzar | estat >= 500 |
| Agregar | Cal escriure un analitzador | Directe |
| Camps nous | Trenquen els analitzadors existents | S'ignoren si no interessen |
| Correlacionar | A ull | Per tracaId |
| Llegible per humans | Sí | En cru no; amb un formatador, sí |
L'únic desavantatge real —la llegibilitat en desenvolupament— es resol amb un formatador, així que no hi ha motiu per no estructurar.
Dues regles de fons que eviten la majoria dels problemes:
Els logs són esdeveniments, no frases. "L'usuari cli_842 ha creat la comanda com_5001" obliga a extreure els identificadors amb una expressió regular. {"esdeveniment":"comanda_creada","clientId":"cli_842","comandaId":"com_5001"} és consultable.
La ruta es registra com a plantilla, no com a URI. "/v1/comandes/:id/pagament", mai "/v1/comandes/com_5001/pagament". Si registres la URI real, no pots agrupar: cada comanda produeix una ruta diferent i les estadístiques per endpoint són impossibles. L'identificador va al seu propi camp, on sí que serveix per filtrar.
- pino i el registrador de la Botiga Aroma
pino és el registrador estàndard de facto a Node: escriu JSON, és extremadament ràpid —serialitza fora del fil crític— i porta de sèrie la redacció de camps sensibles i els registradors fills.
// src/config/registrador.js (fitxer NOU)
import pino from 'pino';
import { entorn } from './entorn.js';
const esDesenvolupament = entorn.NODE_ENV === 'desenvolupament';
export const registrador = pino({
// 1. Nivell mínim: en producció, info; en desenvolupament, debug.
level: entorn.NIVELL_LOG ?? (esDesenvolupament ? 'debug' : 'info'),
// 2. Noms de camp en català i coherents amb la resta del projecte.
messageKey: 'missatge',
errorKey: 'error',
timestamp: pino.stdTimeFunctions.isoTime, // ISO-8601 UTC, com el contracte
formatters: {
// Per defecte pino emet level:30 (numèric). Preferim l'etiqueta.
level: (etiqueta) => ({ nivell: etiqueta }),
},
// 3. Context fix a TOTES les línies: identifica el procés que les va emetre.
base: {
servei: 'api-botigaaroma',
versio: entorn.VERSIO_APP,
entorn: entorn.NODE_ENV,
instancia: entorn.NOM_INSTANCIA ?? 'local',
},
// 4. Redacció automàtica: apartat 6.
redact: {
paths: [
'req.headers.authorization',
'req.headers.cookie',
'req.headers["idempotency-key"]',
'req.body.contrasenya',
'req.body.contrasenyaActual',
'req.body.token',
'req.body.refreshToken',
'res.headers["set-cookie"]',
'*.contrasenya',
'*.hashContrasenya',
'*.hash_contrasenya',
'*.accessToken',
'*.refreshToken',
'*.numeroTargeta',
'*.cvv',
],
censor: '[REDACTAT]',
},
// 5. En desenvolupament, sortida llegible. En producció, JSON pur a stdout.
transport: esDesenvolupament
? { target: 'pino-pretty', options: { colorize: true, translateTime: 'HH:MM:ss' } }
: undefined,
});Cinc decisions explicades:
Els nivells de pino són trace (10), debug (20), info (30), warn (40), error (50) i fatal (60). El criteri de la Botiga Aroma:
| Nivell | Quan | Exemple |
|---|---|---|
trace |
Mai en producció | Bolcat d'una consulta completa |
debug |
Desenvolupament i depuració puntual | «Fallada de memòria cau per a cafe:caf_001» |
info |
Esdeveniments normals de negoci | «Comanda creada», petició completada |
warn |
Anomalia que no trenca res | Consulta lenta, 429 emès, Redis caigut |
error |
Una fallada que afecta la petició | 500 amb la seva pila |
fatal |
El procés no pot continuar | No es pot obrir la base de dades en arrencar |
Un error freqüent: registrar els 4xx com a error. Un 404 o un 400 són comportament normal de l'API, no fallades del sistema. Si els marques com a error, les teves alertes s'ompliran de soroll i deixaràs de mirar-les. Només els 5xx són error.
base afegeix servei, versio, entorn i instancia a cada línia. Sense això, en un sistema amb diverses instàncies no sabràs quina va emetre què, ni podràs distingir un problema d'un desplegament concret.
Sortida a stdout. No a un fitxer. En un desplegament modern, el procés escriu a la sortida estàndard i qui recull, rota i envia els logs és la plataforma. Escriure a fitxer des de l'aplicació complica la rotació, els permisos i els contenidors.
- Què registrar sempre i què no registrar mai
Sempre
| Camp | Per què |
|---|---|
tracaId |
Correlaciona els tres pilars i la resposta al client |
metode |
Filtrar per tipus d'operació |
ruta (plantilla) |
Agrupar per endpoint |
estat |
Filtrar errors |
duracioMs |
Detectar lentitud |
clientId |
Reproduir el problema de l'usuari que es queixa |
clientOauth |
Distingir la SPA de CataBox (04-03) |
ip |
Correlacionar abús — és dada personal: vegeu a sota |
midaResposta |
Detectar respostes anòmales |
codiError |
Agrupar per tipus de fallada del catàleg |
Mai
| No registrar | Per què |
|---|---|
| Contrasenyes, en clar o amb hash | Evident, i passa constantment |
Capçalera Authorization |
Un token als logs és una sessió robada |
| Tokens de qualsevol tipus | Ídem |
| Números de targeta, CVV, IBAN | PCI-DSS i sentit comú |
| Correus, telèfons, adreces | RGPD: minimització |
| El cos complet de la petició | Conté tot l'anterior |
| Galetes | Sessions |
| Claus i secrets de configuració | — |
Dos matisos que no són obvis:
La IP és una dada personal sota el RGPD. Registrar-la sol estar justificat per seguretat (interès legítim), però exigeix una política de retenció curta i documentada. Una opció prudent és desar un hash amb sal en comptes de la IP, que permet correlacionar sense identificar.
El clientId també és un identificador personal, però és pseudònim i necessari per operar. Es registra l'identificador (cli_842), mai el nom ni el correu: si necessites saber qui és, es consulta la base de dades amb els controls d'accés corresponents.
Retenció. Els logs es conserven un temps limitat i documentat —30, 90, 180 dies segons el tipus— perquè cada dia de retenció de més és risc i cost. I cal recordar que els logs entren dins l'àmbit del dret de supressió del RGPD, una raó més per no ficar-hi dades identificatives.
- Redacció automàtica de camps sensibles
La redacció manual falla sempre, perquè n'hi ha prou que algú afegeixi un camp nou o registri un objecte sencer:
// ❌ Un descuit i la contrasenya acaba al sistema de logs amb retenció de 90 dies.
registrador.info({ cos: req.body }, 'petició rebuda');Per això el redact de pino, ja configurat, funciona a nivell del registrador: s'apliqui on s'apliqui, aquells camins es censuren.
registrador.info({ req: { body: { email: '[email protected]', contrasenya: 'Ficticia123' } } }, 'registre');
// → {"nivell":"info", ..., "req":{"body":{"email":"[email protected]","contrasenya":"[REDACTAT]"}}}Els comodins (*.contrasenya) cobreixen qualsevol profunditat, cosa que atrapa objectes imbricats que no havies previst.
Dos reforços que convé afegir:
Una prova que ho verifiqui. La redacció és d'aquelles coses que es trenquen en un refactor sense que ningú no se n'adoni:
// proves/unitaries/redaccio.prova.js
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import pino from 'pino';
import { Writable } from 'node:stream';
describe('redacció de camps sensibles', () => {
it('censura contrasenyes i tokens a qualsevol profunditat', () => {
let sortida = '';
const desti = new Writable({
write(tros, _cod, cb) { sortida += tros.toString(); cb(); },
});
const log = pino(
{ redact: { paths: ['*.contrasenya', '*.accessToken', 'req.headers.authorization'],
censor: '[REDACTAT]' } },
desti
);
log.info({
usuari: { email: '[email protected]', contrasenya: 'Ficticia123' },
sessio: { accessToken: 'eyJhbGciOi...' },
req: { headers: { authorization: 'Bearer eyJhbGciOi...' } },
});
assert.ok(!sortida.includes('Ficticia123'), 'la contrasenya NO ha d\'aparèixer');
assert.ok(!sortida.includes('eyJhbGciOi'), 'cap token no ha d\'aparèixer');
assert.equal((sortida.match(/\[REDACTAT\]/g) ?? []).length, 3);
});
});Un escàner a la integració contínua que busqui patrons de secret als logs de les proves. És la mateixa idea que l'escàner de secrets del repositori a 04-02.
- El middleware
src/middleware/registre.js
src/middleware/registre.jsAquí substituïm per fi el console.log de la posició 5.
// src/middleware/registre.js (fitxer NOU — substitueix el console.log de 03-02)
import pinoHttp from 'pino-http';
import { registrador } from '../config/registrador.js';
export const registrarPeticions = pinoHttp({
logger: registrador,
// 1. L'identificador de la petició ÉS el nostre tracaId, ja assignat a la
// posició 2 de la cadena. Així el log, la resposta i la traça coincideixen.
genReqId: (req) => req.tracaId,
// 2. Nom del camp on pino-http posa aquell identificador.
customProps: (req) => ({
tracaId: req.tracaId,
clientId: req.usuari?.id ?? null,
clientOauth: req.usuari?.clientOauth ?? null,
// Plantilla, no URI: req.route existeix quan la ruta ja ha coincidit.
ruta: req.route?.path ? `${req.baseUrl}${req.route.path}` : req.path,
}),
// 3. Nivell segons el resultat: els 4xx són comportament normal.
customLogLevel: (req, res, err) => {
if (err || res.statusCode >= 500) return 'error';
if (res.statusCode === 429) return 'warn'; // interessa vigilar-los (04-04)
if (res.statusCode >= 400) return 'info'; // NO error: és un 4xx normal
return 'info';
},
customSuccessMessage: (req, res) => `${req.method} ${req.path} → ${res.statusCode}`,
customErrorMessage: (req, res, err) => `${req.method} ${req.path} → ${res.statusCode}: ${err.message}`,
// 4. Serialitzadors: es tria EXPLÍCITAMENT què es registra de req i res.
// Sense això, pino-http registraria totes les capçaleres, inclosa Authorization.
serializers: {
req: (req) => ({
metode: req.method,
url: req.url,
// Només capçaleres innòcues, i en llista blanca.
capcaleres: {
'content-type': req.headers['content-type'],
'content-length': req.headers['content-length'],
'user-agent': req.headers['user-agent'],
accept: req.headers.accept,
},
ip: req.ip,
}),
res: (res) => ({
estat: res.statusCode,
mida: res.getHeader?.('content-length'),
}),
},
// 5. /salut i /metriques es criden constantment: no embruten els logs.
autoLogging: {
ignore: (req) => req.url === '/salut' || req.url === '/metriques',
},
});El punt 4 és el més important per a la seguretat: per defecte, pino-http registra totes les capçaleres de la petició, inclosa Authorization. La llista blanca de serialitzadors és el que ho impedeix, i és defensa en profunditat juntament amb redact.
El registrador fill per petició
Un registrador fill és un registrador que arrossega automàticament un context fix. pino-http el crea a req.log, i a partir d'aquí qualsevol línia que escriguis dins de la petició porta el tracaId sense que l'hagis de passar:
// src/controladors/comandes.js (MODIFICAT)
export async function crear(req, res) {
// req.log és el registrador fill: ja porta tracaId, clientId i ruta.
req.log.info({ linies: req.dadesValidades.linies.length }, 'creant comanda');
const comanda = await serveis.comandes.crear(req.dadesValidades, req.usuari, req.log);
req.log.info({ comandaId: comanda.id, totalEuros: comanda.totalEuros }, 'comanda creada');
res.status(201).location(`/v1/comandes/${comanda.id}`).json(comanda);
}Passar req.log al servei és la manera que les capes internes registrin amb el mateix context sense conèixer HTTP. L'alternativa —AsyncLocalStorage de Node— evita el pas explícit i és el que fan servir les solucions més avançades; per a un projecte d'aquesta mida, passar el registrador és més simple i més explícit.
La posició a src/app.js
// src/app.js (VERSIÓ FINAL DEL MÒDUL 4)
import express from 'express';
import cors from 'cors';
import compression from 'compression';
import { rutesV1 } from './rutes/index.js';
import { assignarTracaId } from './middleware/traca.js';
import { capcaleresSeguretat } from './middleware/seguretat.js';
import { opcionsCors } from './config/cors.js';
import { registrarPeticions } from './middleware/registre.js'; // ← NOU
import { limitGlobal } from './middleware/limit-peticions.js';
import { etagCondicional } from './middleware/cache.js';
import { metriquesMiddleware, gestorMetriques, protegirMetriques } from './observabilitat/metriques.js'; // ← NOU
import { gestorSalutViva, gestorSalutPreparada } from './rutes/salut.js'; // ← NOU
// opcionsCompressio es va definir a 04-06, en aquest mateix fitxer.
import { gestorNoTrobat } from './middleware/no-trobat.js';
import { gestorErrors } from './middleware/errors.js';
export const app = express();
app.disable('x-powered-by'); // 1
app.set('trust proxy', 1);
app.use(assignarTracaId); // 2
app.use(capcaleresSeguretat); // 3 helmet (04-02)
app.use(cors(opcionsCors)); // 4 (04-05)
app.use(registrarPeticions); // 5 ← substitueix el console.log
app.use(metriquesMiddleware); // 6 ← NOU (04-07)
app.use(limitGlobal); // 7 (04-04)
app.use(compression(opcionsCompressio)); // 8 (04-06)
app.use(express.json({ limit: '100kb', type: ['application/json', 'application/merge-patch+json'] })); // 9
app.use(express.urlencoded({ extended: false, limit: '10kb' }));
app.get('/salut', gestorSalutViva); // 10 liveness
app.get('/salut/preparat', gestorSalutPreparada); // 11 readiness
app.get('/metriques', protegirMetriques, gestorMetriques); // 12 ← NOU, fora de /v1
app.use(etagCondicional); // 13 (04-06)
app.use('/v1', rutesV1); // 14
app.use(gestorNoTrobat); // 15
app.use(gestorErrors); // 16Per què el registre a la 5 i les mètriques a la 6:
- Després de CORS (4): si CORS rebutja alguna cosa, no hi ha res a registrar com a petició de l'API.
- Abans del rate limiting (7): si fos després, els
429no es registrarien ni es comptarien, i quedaries cec justament durant un atac. - Abans de l'analitzador (9): per registrar també les peticions amb JSON mal format, que produeixen un
400i són senyal d'un client trencat. - Les mètriques just després del registre, per mesurar totes les peticions que es registren, sense desfasament entre tots dos.
- Correlació d'extrem a extrem amb
Aroma-Traca-Id
Aroma-Traca-IdEl tracaId que emetem des de 03-02 cobra ara tot el seu sentit. El seu recorregut:
graph LR SPA[SPA: genera o rep Aroma-Traca-Id] --> API[API: hereta o genera] API --> LOG[Logs: tracaId a cada linia] API --> INV[gRPC inventari: propaga l'id] API --> DB[Consulta SQL: es registra amb l'id] API --> WH[Webhook a RapidEnviaments: Aroma-Traca-Id] API --> RES[Resposta: capcalera + tracaId en 5xx] RES --> USR[L'usuari veu trz_9f3a2b7c al missatge d'error] USR --> SOP[Suport busca aquell id i ho veu TOT]
// src/middleware/traca.js (MODIFICAT: s'hi afegeix la interoperabilitat amb W3C)
import crypto from 'node:crypto';
export function assignarTracaId(req, res, next) {
// 1. S'hereta la del client si ve i té format vàlid.
// Validar el format és important: és entrada no fiable i acaba als logs.
const heretada = req.get('Aroma-Traca-Id');
const valida = heretada && /^trz_[0-9a-f]{8,32}$/.test(heretada);
req.tracaId = valida ? heretada : `trz_${crypto.randomBytes(4).toString('hex')}`;
// 2. Si arriba un traceparent de W3C (04-07 §14), es conserva el trace-id
// per poder correlacionar amb sistemes que no parlen el nostre dialecte.
const traceparent = req.get('traceparent');
if (traceparent) {
const parts = traceparent.split('-');
if (parts.length === 4) req.traceIdW3C = parts[1];
}
// 3. Sempre es retorna, perquè el client la pugui mostrar.
res.set('Aroma-Traca-Id', req.tracaId);
next();
}La validació del format del punt 1 no és paranoia: sense ella, un atacant pot injectar salts de línia a la capçalera i falsificar línies de log completes (log injection), o ficar-hi càrregues útils que explotin el visor de logs.
A la SPA, el cicle es tanca mostrant l'identificador a l'usuari:
// Client: mostrar la traça als errors fa el suport deu vegades més ràpid.
const resposta = await fetch(url, opcions);
if (!resposta.ok) {
const traca = resposta.headers.get('Aroma-Traca-Id'); // llegible gràcies a 04-05
mostrarError(`Alguna cosa ha anat malament. Si contactes amb suport, indica la referència ${traca}.`);
}I perquè això funcioni, Aroma-Traca-Id havia de ser a exposedHeaders de 04-05. Totes les peces encaixen.
- Errors 5xx: què es registra enfront de què es retorna
A 03-07 vam prometre que el tracaId seria el pont entre el que veu el client i el que veu l'equip. Aquí es materialitza.
// src/middleware/errors.js (MODIFICAT — fragment)
export function gestorErrors(err, req, res, next) {
const esErrorApi = err instanceof ErrorApi;
const estat = esErrorApi ? err.estat : 500;
if (estat >= 500) {
// A L'EQUIP: absolutament tot.
req.log.error(
{
err, // pino serialitza missatge, tipus i pila completa
codiError: esErrorApi ? err.codi : 'error_intern',
ruta: req.route?.path ?? req.path,
metode: req.method,
clientId: req.usuari?.id ?? null,
// El cos NO es registra: pot contenir dades personals o secrets.
},
'error no controlat'
);
} else if (estat === 429) {
req.log.warn({ codiError: err.codi, clau: req.rateLimit?.key }, 'límit assolit');
} else {
// 4xx: nivell info. Són comportament normal de l'API.
req.log.info({ codiError: err.codi, estat }, 'petició rebutjada');
}
// AL CLIENT: el mínim, i el tracaId només en 5xx (contracte de 03-07).
const cos = {
error: {
codi: esErrorApi ? err.codi : 'error_intern',
missatge: esErrorApi ? err.missatge : 'S\'ha produït un error inesperat.',
detalls: esErrorApi ? (err.detalls ?? []) : [],
},
};
if (estat >= 500) cos.error.tracaId = req.tracaId;
res.set('Cache-Control', 'no-store'); // els errors no es desen a la memòria cau (04-06)
res.status(estat).json(cos);
}L'asimetria, en una taula:
| Resposta al client | Log de l'equip | |
|---|---|---|
| Codi | error_intern |
El real, amb el seu tipus d'excepció |
| Missatge | Genèric | L'intern complet |
| Pila | Mai | Completa |
| SQL | Mai | Sí, la plantilla |
tracaId |
Sí (només 5xx) | Sí |
| Cos de la petició | — | Tampoc: dades personals |
Fixa't en l'última fila: ni tan sols a l'equip se li registra el cos. La temptació de «desar-ho tot per si de cas» és exactament com acaben les contrasenyes en un sistema de logs amb retenció d'un any.
- Mètriques: els quatre senyals d'or i el mètode RED
Els quatre senyals d'or (del llibre d'SRE de Google):
| Senyal | Què mesura | A la Botiga Aroma |
|---|---|---|
| Latència | Quant triga | Histograma per ruta i estat |
| Trànsit | Quanta demanda hi ha | Peticions per segon |
| Errors | Quina proporció falla | Taxa de 5xx |
| Saturació | Com de ple està | Connexions a la BD, memòria, cua d'esdeveniments |
El mètode RED és la versió simplificada per a serveis de petició-resposta, i és la que s'aplica a una API REST:
- Rate: peticions per segon.
- Errors: peticions fallides per segon.
- Duration: distribució de la latència.
Amb aquestes tres, per ruta i per codi d'estat, cobreixes el 90 % del que necessites d'una API. La saturació s'hi afegeix com a mètriques de recursos: memòria del procés, event loop, pool de connexions.
Un matís que evita un error comú sobre la latència: cal mesurar-la separant els èxits dels errors. Un 401 respon en 2 ms, així que una allau de 401 millora la teva latència mitjana mentre el servei està trencat. Separar per estat ho evita.
- Tipus de mètrica i per què l'histograma
| Tipus | Què és | Exemple | Operacions |
|---|---|---|---|
| Comptador | Només puja; es reinicia en reiniciar el procés | Peticions totals | Taxa per segon |
| Gauge | Puja i baixa | Connexions actives | Valor actual, màxim |
| Histograma | Distribució en cubells | Latència | Percentils, mitjana |
| Summary | Percentils calculats al client | Latència | No agregable entre instàncies |
Per què l'histograma i no la mitjana. Com vam veure a 04-06, la mitjana menteix. Però a més, si cada instància enviés la seva mitjana, aquelles mitjanes no es poden combinar correctament: la mitjana de les mitjanes no és la mitjana global llevat que totes tinguin el mateix nombre de mostres.
Un histograma resol totes dues coses. Es defineixen cubells i es compta quantes observacions cauen a cadascun:
latencia_bucket{le="0.005"} 1200 ← 1200 peticions sota 5 ms
latencia_bucket{le="0.01"} 3400
latencia_bucket{le="0.05"} 8900
latencia_bucket{le="0.1"} 9500
latencia_bucket{le="0.5"} 9950
latencia_bucket{le="+Inf"} 10000Els cubells sí que són sumables entre instàncies, i el percentil es calcula després interpolant. En PromQL:
# p99 de la latència per ruta, en una finestra de 5 minuts.
histogram_quantile(0.99, sum(rate(aroma_http_duracio_segons_bucket[5m])) by (le, ruta))La precisió depèn dels cubells: si el p99 real és 180 ms i els teus cubells salten de 100 ms a 500 ms, obtindràs una interpolació pobra. Els cubells es trien a partir del teu pressupost de latència (04-06), posant fronteres al voltant dels valors que t'importen.
- Instrumentar amb prom-client i exposar
/metriques
/metriques// src/observabilitat/metriques.js (fitxer NOU)
import client from 'prom-client';
import { entorn } from '../config/entorn.js';
export const registre = new client.Registry();
// Mètriques del procés: CPU, memòria, event loop, descriptors. De franc i molt útils.
client.collectDefaultMetrics({ register: registre, prefix: 'aroma_' });
// --- 1. Trànsit i errors: un comptador per ruta, mètode i estat ---
const peticions = new client.Counter({
name: 'aroma_http_peticions_total',
help: 'Peticions HTTP ateses',
labelNames: ['metode', 'ruta', 'estat'],
registers: [registre],
});
// --- 2. Latència: histograma amb cubells alineats al pressupost de 04-06 ---
const duracio = new client.Histogram({
name: 'aroma_http_duracio_segons',
help: 'Durada de les peticions HTTP',
labelNames: ['metode', 'ruta', 'estat'],
buckets: [0.005, 0.015, 0.03, 0.05, 0.08, 0.15, 0.3, 0.5, 1, 2, 5],
registers: [registre],
});
// --- 3. Mètriques de negoci: les que de debò diuen si la botiga funciona ---
export const comandesCreades = new client.Counter({
name: 'aroma_comandes_creades_total',
help: 'Comandes creades',
labelNames: ['origenClient'], // spa | mobil | catabox — cardinalitat baixa
registers: [registre],
});
export const estocEsgotat = new client.Counter({
name: 'aroma_estoc_esgotat_total',
help: 'Intents de compra rebutjats per manca d\'estoc',
labelNames: ['torrefaccio'], // 3 valors possibles: segur
registers: [registre],
});
export const limitsEmesos = new client.Counter({
name: 'aroma_limit_peticions_total',
help: 'Respostes 429 emeses',
labelNames: ['nivell'], // anonim | client | soci | tauler
registers: [registre],
});
export const cacheEncerts = new client.Counter({
name: 'aroma_cache_total',
help: 'Accessos a la memòria cau d\'aplicació',
labelNames: ['resultat'], // encert | fallada
registers: [registre],
});
/**
* Middleware d'instrumentació. Posició 6 de src/app.js.
*/
export function metriquesMiddleware(req, res, next) {
const fi = duracio.startTimer();
res.on('finish', () => {
// CLAU: la plantilla de ruta, MAI la URI amb identificadors. Vegeu §13.
// Si cap ruta no ha coincidit (404), s'agrupa sota 'desconeguda' per no
// generar una etiqueta per cada URL inexistent que algú provi.
const ruta = req.route?.path ? `${req.baseUrl}${req.route.path}` : 'desconeguda';
const etiquetes = { metode: req.method, ruta, estat: String(res.statusCode) };
peticions.inc(etiquetes);
fi(etiquetes);
});
next();
}
/** Endpoint /metriques: format de text de Prometheus. */
export async function gestorMetriques(req, res) {
res.set('Content-Type', registre.contentType);
res.set('Cache-Control', 'no-store');
res.end(await registre.metrics());
}
/**
* Protecció de /metriques: exposa rutes internes, versions i volum de negoci.
* MAI no ha de ser públic.
*/
export function protegirMetriques(req, res, next) {
const token = (req.get('Authorization') ?? '').replace('Bearer ', '');
if (token !== entorn.METRIQUES_TOKEN) return res.status(404).end(); // 404, no 401
return next();
}/metriques va fora de /v1, igual que /salut: no forma part del contracte de negoci ni es versiona amb ell. I ha d'estar protegit: revela les teves rutes internes, les teves versions, el teu volum de comandes i les teves taxes d'error, informació valuosa tant per a un competidor com per a un atacant. Es respon 404 en comptes de 401 per no confirmar ni tan sols que existeix. En un desplegament real, a més, s'exposa només a la xarxa interna o en un port diferent no publicat.
Exemple d'instrumentació de negoci:
// src/serveis/comandes.js (MODIFICAT — fragment)
import { comandesCreades, estocEsgotat } from '../observabilitat/metriques.js';
export async function crear(dades, solicitant, log) {
for (const linia of dades.linies) {
const cafe = await repositoris.cafes.buscarPerId(linia.cafeId);
if (cafe.estoc < linia.quantitat) {
estocEsgotat.inc({ torrefaccio: cafe.torrefaccio });
log.warn({ cafeId: cafe.id, sollicitat: linia.quantitat, disponible: cafe.estoc },
'estoc insuficient');
throw errors.conflicte('estoc_insuficient', `No hi ha prou estoc de ${cafe.nom}.`);
}
}
const comanda = await repositoris.comandes.crear(dades);
comandesCreades.inc({ origenClient: solicitant.clientOauth ?? 'spa' });
return comanda;
}Les mètriques de negoci són les més valuoses i les que gairebé ningú no posa. «Les comandes per minut han caigut a zero» detecta incidents que cap mètrica tècnica no veu: l'API respon 200, la CPU està bé, i tanmateix un canvi a la SPA ha trencat el botó de comprar.
- Cardinalitat: per què
com_5001 rebenta el sistema
com_5001 rebenta el sistemaLa cardinalitat d'una mètrica és el nombre de combinacions diferents d'etiquetes. Cada combinació és una sèrie temporal independent, amb la seva pròpia memòria i el seu propi cost.
// ✅ Cardinalitat acotada i predictible.
// 6 mètodes × 24 rutes × ~8 estats = ~1.150 sèries. Perfectament manejable.
peticions.inc({ metode: 'GET', ruta: '/v1/comandes/:id', estat: '200' });
// ❌ CATASTRÒFIC: una sèrie temporal per CADA comanda, per sempre.
// Amb 100.000 comandes: 100.000 sèries. El sistema de mètriques cau.
peticions.inc({ metode: 'GET', ruta: '/v1/comandes/com_5001', estat: '200' });Se'n diu explosió de cardinalitat i és la manera número u de tombar un sistema de mètriques —de vegades juntament amb la resta del clúster.
| Etiqueta | Valors possibles | És segura? |
|---|---|---|
metode |
6 | Sí |
ruta (plantilla) |
~24 | Sí |
estat |
~8 | Sí |
torrefaccio |
3 | Sí |
rol |
4 | Sí |
clientOauth |
~10 | Sí |
clientId |
Milions | No |
comandaId |
Il·limitats | No |
ip |
Il·limitats | No |
tracaId |
Un per petició | Mai |
userAgent |
Milers | No |
La regla mnemotècnica:
Els identificadors van als logs i a les traces. A les mètriques hi van només categories amb un nombre petit i conegut de valors.
Quan necessitis investigar un cas concret, el camí correcte és: la mètrica et diu que hi ha un problema i on; el log i la traça et diuen quin. Mai a l'inrevés.
Compte també amb el 404 de rutes inexistents: si registressis req.path sense coincidència de ruta, qualsevol podria fer explotar la teva cardinalitat demanant URL aleatòries. Per això el middleware de l'apartat 12 agrupa sota 'desconeguda'.
- Traces distribuïdes: spans, context i
traceparent
traceparentQuan una petició travessa diversos serveis, els logs de cadascun expliquen un tros de la història. Una traça els cus.
| Concepte | Què és |
|---|---|
| Traça | El recorregut complet d'una petició per tot el sistema |
| Span (tram) | Una unitat de treball dins de la traça (una consulta, una crida) |
| Span pare | El tram que va originar un altre: dona l'estructura d'arbre |
| Context de traça | El que es propaga entre serveis per unir els trams |
| Mostreig | Desar només un percentatge: traçar-ho tot és caríssim |
L'estàndard de propagació és W3C Trace Context, amb la capçalera traceparent:
traceparent: 00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01
↑ ↑ ↑ ↑
versió trace-id (16 bytes) span-id (8 bytes) flags- El trace-id és el mateix a tota la traça: és el que uneix els serveis.
- El span-id identifica l'operació actual; el servei següent el farà servir com a pare.
- Els flags indiquen, entre altres coses, si aquesta traça està mostrejada.
OpenTelemetry és l'estàndard d'instrumentació (API, SDK i protocol) que s'ha imposat al sector; el seu principal avantatge és que desacobla la instrumentació del backend: instrumentes una vegada i pots enviar a Jaeger, Tempo, Datadog o el que facis servir després.
// src/observabilitat/traces.js (fitxer NOU)
import { NodeSDK } from '@opentelemetry/sdk-node';
import { getNodeAutoInstrumentations } from '@opentelemetry/auto-instrumentations-node';
import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-http';
import { entorn } from '../config/entorn.js';
/**
* S'ha d'inicialitzar ABANS d'importar express i altres llibreries: la
* instrumentació automàtica les pedaça en carregar-se. Per això src/servidor.js
* fa `import './observabilitat/traces.js'` a la seva primera línia.
*/
export const sdk = new NodeSDK({
serviceName: 'api-botigaaroma',
traceExporter: new OTLPTraceExporter({ url: entorn.OTLP_ENDPOINT }),
instrumentations: [
getNodeAutoInstrumentations({
// El sondeig de salut generaria milers de traces sense valor.
'@opentelemetry/instrumentation-http': {
ignoreIncomingRequestHook: (req) =>
req.url === '/salut' || req.url === '/salut/preparat' || req.url === '/metriques',
},
// El sistema de fitxers produeix moltíssim soroll i gairebé cap valor.
'@opentelemetry/instrumentation-fs': { enabled: false },
}),
],
});
if (entorn.OTLP_ENDPOINT) sdk.start();Les instrumentacions automàtiques cobreixen HTTP entrant i sortint, Express, gRPC i els clients de base de dades habituals, així que la major part de la traça apareix sense escriure codi. Els trams manuals s'afegeixen només on hi ha lògica de negoci que vols veure.
Mostreig. Traçar el 100 % del trànsit és car en xarxa, emmagatzematge i CPU. L'habitual és un mostreig baix (1–10 %) amb dues excepcions: sempre es conserven les traces de peticions amb error i les lentes, que són justament les que interessen.
- Una traça de
POST /v1/comandes
POST /v1/comandessequenceDiagram participant SPA as SPA participant API as API Aroma participant DB as SQLite participant INV as Servei inventari (gRPC) participant RE as RapidEnviaments (webhook) SPA->>API: POST /v1/comandes (traceparent) Note over API: tram: validacio Zod (3 ms) API->>INV: ReservarEstoc (gRPC, propaga traceparent) INV->>API: reservat (48 ms) Note over API: tram: transaccio API->>DB: INSERT comanda (6 ms) API->>DB: INSERT linies (4 ms) API->>DB: UPDATE estoc (3 ms) API->>RE: POST webhook comanda.creada (asincron, 120 ms) API->>SPA: 201 Created (total 68 ms)
Vista com a arbre de trams, amb la durada de cadascun:
POST /v1/comandes ........................ 68 ms [traça: 4bf92f35...] ├── middleware.autenticar ................ 2 ms ├── middleware.validar ................... 3 ms ├── grpc.inventari.ReservarEstoc ......... 48 ms ← el 70 % del temps │ └── inventari.consultaMagatzem ....... 41 ms ├── db.transaccio ........................ 13 ms │ ├── db.insert.comandes ............... 6 ms │ ├── db.insert.linies_comanda ......... 4 ms │ └── db.update.cafes.estoc ............ 3 ms └── webhook.comanda_creada (asíncron) .... 120 ms (fora del camí crític)
El que es veu d'un cop d'ull i que cap altra eina no t'hauria donat:
- El gRPC d'inventari consumeix el 70 % del temps. Optimitzar la consulta SQL, que suma 13 ms, seria treballar al lloc equivocat.
- El webhook és asíncron i no bloqueja la resposta. Si estigués dins del camí crític, el
201trigaria 188 ms. - La transacció està ben acotada: tres escriptures seguides, sense crides de xarxa a dins, que és exactament el que es buscava a 03-05. Una crida gRPC dins de la transacció mantindria el bloqueig de la base de dades durant 48 ms.
Un tram manual, quan la instrumentació automàtica no hi arriba:
// src/serveis/comandes.js (fragment)
import { trace } from '@opentelemetry/api';
const tracador = trace.getTracer('api-botigaaroma');
export async function calcularTotal(linies) {
return tracador.startActiveSpan('comandes.calcularTotal', async (span) => {
try {
span.setAttribute('linies.quantitat', linies.length); // atribut de baixa cardinalitat
const total = /* ... càlcul en cèntims ... */ 4190;
span.setAttribute('total.centims', total);
return total;
} catch (error) {
span.recordException(error);
span.setStatus({ code: 2 }); // ERROR
throw error;
} finally {
span.end(); // SEMPRE, o el tram queda obert
}
});
}
- Comprovacions de salut: liveness i readiness
Una única /salut barreja dues preguntes diferents, i confondre-les provoca incidents en el desplegament.
Liveness (/salut) |
Readiness (/salut/preparat) |
|
|---|---|---|
| Pregunta | És viu, el procés? | Pot atendre peticions ara? |
| Si falla | Es reinicia el procés | Se'l treu del balanceig, sense reiniciar |
| Comprova | Res extern | Base de dades, migracions, Redis |
| Cost | Mínim | Pot consultar dependències |
| Freqüència | Cada 10 s | Cada 5 s |
// src/rutes/salut.js (fitxer NOU)
import { db } from '../config/base-dades.js';
import { redis } from '../config/redis.js';
import { entorn } from '../config/entorn.js';
/**
* LIVENESS: només diu que el procés respon.
* NO comprova dependències: si la base de dades cau, reiniciar l'API no
* l'arregla, i reiniciar en bucle totes les instàncies empitjora l'incident.
*/
export function gestorSalutViva(req, res) {
res.set('Cache-Control', 'no-store');
res.status(200).json({ estat: 'ok', versio: entorn.VERSIO_APP });
}
/**
* READINESS: pot aquesta instància atendre peticions?
*/
export async function gestorSalutPreparada(req, res) {
res.set('Cache-Control', 'no-store');
const comprovacions = {};
let preparat = true;
// 1. Base de dades: imprescindible. Consulta trivial, sense tocar dades.
try {
db.prepare('SELECT 1').get();
comprovacions.baseDades = 'ok';
} catch (error) {
comprovacions.baseDades = 'error';
preparat = false;
req.log.error({ err: error }, 'readiness: base de dades no disponible');
}
// 2. Migracions al dia: arrencar amb l'esquema vell produeix errors estranys.
try {
const { versio } = db.prepare('SELECT MAX(versio) AS versio FROM migracions').get();
comprovacions.migracions = versio >= entorn.MIGRACIO_MINIMA ? 'ok' : 'desactualitzades';
if (comprovacions.migracions !== 'ok') preparat = false;
} catch {
comprovacions.migracions = 'error';
preparat = false;
}
// 3. Redis: DEGRADAT, no fatal. Sense memòria cau ni rate limiting compartit l'API
// funciona pitjor, però funciona. Treure-la del balanceig seria contraproduent.
try {
await redis.ping();
comprovacions.redis = 'ok';
} catch {
comprovacions.redis = 'degradat';
}
res.status(preparat ? 200 : 503).json({
estat: preparat ? 'preparat' : 'no_preparat',
comprovacions,
});
}Tres decisions que eviten incidents clàssics:
Liveness no comprova la base de dades. Si ho fes i la base de dades caigués, l'orquestrador reiniciaria totes les instàncies en bucle, afegint una tempesta d'arrencades a un incident que ja existia.
Redis és «degradat», no fatal. És coherent amb les decisions de 04-04 (fail-open) i 04-06 (la memòria cau mai no és dependència dura).
Readiness retorna 503, no 500. És «ara no», no «estic trencat», i encaixa amb el servei_no_disponible del catàleg. Durant l'apagada ordenada de 03-07, el correcte és començar retornant 503 a readiness i continuar atenent les peticions en curs: així el balancejador deixa d'enviar trànsit nou abans que el procés mori, i no es perd cap petició. Aquest detall és el que fa possible un desplegament sense errors visibles, i hi tornarem a 05-05.
- Alertes útils, SLO i pressupost d'error
La regla: alerta sobre símptomes que afecten els usuaris, no sobre causes que potser no afecten ningú.
| ❌ Alerta sobre causes | ✅ Alerta sobre símptomes |
|---|---|
| «CPU > 80 %» | «El p99 de /v1/cafes supera 500 ms» |
| «Memòria > 90 %» | «La taxa de 5xx supera l'1 %» |
| «Redis caigut» | «La latència s'ha duplicat» |
| «Disc al 85 %» | «Les comandes per minut han caigut a zero» |
Una CPU al 90 % amb la latència dins del pressupost no és un problema: és un servidor ben aprofitat. Despertar algú per això és la manera més ràpida que les alertes deixin de mirar-se.
SLI, SLO i pressupost d'error
- SLI (indicador): allò que mesures. «Proporció de peticions amb estat < 500».
- SLO (objectiu): el llindar que et compromets a complir. «99,9 % en 30 dies».
- Pressupost d'error: allò que et permets fallar. Amb un 99,9 %, és el 0,1 % del mes: uns 43 minuts.
Els de la Botiga Aroma:
| SLI | SLO | Pressupost mensual |
|---|---|---|
Disponibilitat (< 500) |
99,9 % | 43 min |
Latència GET /v1/cafes p99 < 150 ms |
99 % de les peticions | — |
Latència POST /v1/comandes p99 < 400 ms |
99 % | — |
| Èxit de webhooks a RàpidEnviaments | 99,5 % en 24 h | — |
El pressupost d'error converteix una discussió d'opinions en una regla: si queda pressupost, es despleguen funcionalitats noves; si s'ha esgotat, es dedica el temps a fiabilitat. Ja no cal negociar «arreglem això o traiem allò?»: ho decideix la dada.
I l'alerta correcta no és sobre el valor instantani, sinó sobre la velocitat de consum del pressupost (burn rate): «a aquest ritme, el pressupost del mes s'esgota en dues hores». Això distingeix un pic irrellevant d'un problema real.
# Taxa d'error en 5 minuts, sobre el total de peticions.
sum(rate(aroma_http_peticions_total{estat=~"5.."}[5m]))
/ sum(rate(aroma_http_peticions_total[5m])) > 0.01Cada alerta ha de complir quatre condicions, i si en falla alguna cal esborrar-la: és accionable (hi ha alguna cosa concreta per fer), és urgent (no pot esperar a demà), està documentada (un runbook amb els primers passos) i no és sorollosa (si salta cada dia, o és un problema real que cal arreglar o és una alerta que sobra).
- El tauler mínim del dia del llançament
Sis gràfics. Ni un més, perquè un tauler amb quaranta gràfics no el mira ningú:
| Gràfic | Què mostra | Què busques |
|---|---|---|
| 1. Peticions per segon, per estat | Trànsit i errors junts | Que pugi el trànsit i no els 5xx |
| 2. Latència p50 / p95 / p99 | Tres línies, mateix gràfic | Que el p99 no es dispari |
| 3. Taxa d'error per ruta | Quin endpoint falla | Un endpoint concret trencant-se |
| 4. Comandes creades per minut | La mètrica de negoci | Que no caigui a zero |
5. 429 emesos per nivell |
Efecte del rate limiting | Que no estiguis bloquejant usuaris legítims |
| 6. Saturació: memòria, event loop, pool BD | Recursos | Fuites i esgotament |
Els gràfics 4 i 5 són els que distingeixen un tauler útil d'un de decoratiu. El 4 detecta la fallada que cap mètrica tècnica no veu: tot respon 200 i tanmateix ningú no compra. El 5 és la comprovació directa que la feina de 04-04 no s'ha girat en contra teva.
El dia del llançament, a més: tingues a mà la consulta de logs filtrada per estat >= 500 ordenada per hora, i l'enllaç a les traces de les peticions més lentes. És el que converteix «alguna cosa va malament» en «el gRPC d'inventari està trigant 2 s» en menys d'un minut.
- L'stack habitual
| Pilar | Eines | Nota |
|---|---|---|
| Mètriques | Prometheus + Grafana | L'estàndard de facto; Prometheus recol·lecta de /metriques |
| Logs | Loki + Grafana, o ELK (Elasticsearch, Logstash, Kibana) | Loki és més barat: indexa etiquetes, no el text |
| Traces | Jaeger, Grafana Tempo | Tots dos parlen OTLP |
| Tot en un | Grafana Cloud, Datadog, New Relic, Honeycomb | De pagament, sense operació pròpia |
| Instrumentació | OpenTelemetry | Estàndard; et desacobla del backend |
Dos consells en triar, sense entrar en instal·lacions:
Comença per allò gestionat. Operar Prometheus, Loki i Jaeger és una feina a temps parcial. Per a un projecte com la Botiga Aroma, un servei gestionat costa menys que el temps de mantenir-lo.
Instrumenta amb OpenTelemetry passi el que passi. És l'única cosa que et permet canviar de proveïdor sense tornar a tocar el codi.
- Balanç del mòdul 4
En començar el mòdul tenies una API correcta. Això és el que s'ha endurit:
| Lliçó | Què hi va afegir | Fitxers |
|---|---|---|
| 04-01 | Criteri de disseny, antipatrons, llista de revisió, Spectral | .spectral.yaml, docs/decisions/ |
| 04-02 | OWASP Top 10, helmet, secrets, RGPD, pujades, SSRF | src/middleware/seguretat.js, src/serveis/{descarregues,imatges}.js |
| 04-03 | OAuth 2.0, OIDC, JWKS, àmbits | src/config/oauth.js, src/middleware/autenticacio-oauth.js |
| 04-04 | Rate limiting, 429, Redis, retrocés |
src/middleware/limit-peticions.js, src/config/redis.js |
| 04-05 | CORS amb llista blanca, Expose-Headers |
src/config/cors.js |
| 04-06 | Memòria cau HTTP, ETag, 304, If-Match/412, compressió |
src/middleware/cache.js, src/serveis/cache*.js |
| 04-07 | Logs, mètriques, traces, salut, SLO | src/config/registrador.js, src/middleware/registre.js, src/observabilitat/* |
La cadena de src/app.js ha passat de 8 passos a 16, i cadascun respon a un problema concret que ara saps anomenar. El catàleg d'errors va créixer amb precondicio_requerida, i el d'àmbits va aparèixer amb OAuth. I, sobretot, l'API ha deixat de ser una caixa negra: quan alguna cosa falli, ho sabràs abans que els teus clients i podràs esbrinar què va ser.
Errors Comuns i Consells
Registrar la URI completa en comptes de la plantilla. Impedeix agrupar als logs i fa explotar la cardinalitat a les mètriques.
Registrar el cos de la petició «per depurar». És la via més ràpida per ficar contrasenyes i dades personals en un sistema amb retenció llarga.
Marcar els 4xx com a error. Genera soroll, i el soroll fa que les alertes deixin de mirar-se.
Fer servir identificadors com a etiquetes de mètrica. clientId o comandaId rebenten el sistema de mètriques.
Posar la comprovació de la base de dades a liveness. Converteix una caiguda de la base de dades en un bucle de reinicis de tota la flota.
Deixar /metriques públic. Exposa rutes internes, versions i volum de negoci.
Alertar sobre CPU i memòria. Són causes, no símptomes. Alerta sobre latència, errors i mètriques de negoci.
Mirar la mitjana en comptes dels percentils. Ja ho vam veure a 04-06 i continua sent l'error de mesura més freqüent.
Registrar el registrador sense serialitzadors. pino-http inclou per defecte totes les capçaleres, inclosa Authorization.
Consell: posa el tracaId als missatges d'error de la SPA. Converteix «no em funciona» en una investigació de dos minuts.
Consell: instrumenta el negoci, no només la tècnica. «Comandes per minut» detecta incidents que cap mètrica d'infraestructura no veu.
Consell: revisa les teves alertes cada trimestre. Esborra les que mai no han estat accionables. Un sistema d'alertes amb soroll és pitjor que no tenir-ne cap.
Consell: abans d'un incident, escriu el runbook. A les tres de la matinada ningú no improvisa bé.
Exercicis
Exercici 1: revisar una instrumentació
Aquest codi s'ha proposat per instrumentar l'endpoint de pagament. Troba almenys cinc problemes i corregeix-los.
router.post('/:id/pagament', autenticar, async (req, res) => {
console.log(`Pagament de ${req.params.id}, cos: ${JSON.stringify(req.body)}`);
const inici = Date.now();
try {
const resultat = await serveis.comandes.pagar(req.params.id, req.body);
peticions.inc({ ruta: `/v1/comandes/${req.params.id}/pagament`, client: req.usuari.id });
console.log(`OK en ${Date.now() - inici} ms`);
res.json(resultat);
} catch (error) {
console.error(`ERROR: ${error.stack}`);
res.status(500).json({ error: error.message });
}
});Exercici 2: dissenyar les mètriques d'una funcionalitat
La Botiga Aroma afegeix la subscripció mensual de cafè. Dissenya les mètriques per observar-la: nom, tipus, etiquetes (amb la seva cardinalitat estimada) i quina pregunta respon cadascuna. Inclou almenys una mètrica de negoci i una alerta útil expressada en paraules.
Exercici 3: investigar un incident
A les 10:15 salta l'alerta «taxa de 5xx > 1 % a POST /v1/comandes». Descriu, pas a pas, com faries servir els tres pilars per arribar a la causa, indicant què busques a cadascun i quina conclusió trauries de cada resultat possible.
Solucions
Solució 1
Problemes:
console.logen comptes del registrador. Sense estructura, sense nivell, sensetracaId, i no arriba al sistema de logs amb format.- Registra el cos complet. El cos d'un pagament pot contenir dades de targeta. És una fallada greu de seguretat i de RGPD.
- Cardinalitat explosiva a la mètrica.
rutaamb l'identificador real i una etiquetaclientamb elclientId: una sèrie per cada comanda i per cada client. - Només compta els èxits. L'
inc()és dins deltrydesprés de l'operació, així que els errors no es compten i la taxa d'error és sempre zero. - No mesura la latència com a mètrica, només la imprimeix. No hi ha histograma ni percentils.
- Retorna
error.messageal client. Filtra l'interior del sistema: trenca el que es va establir a 03-07 i 04-02. - No fa servir el gestor d'errors central. El format de l'error no compleix el contracte (
codi,missatge,detalls,tracaId). - Falta
asincron(): un rebuig no capturat deixaria la petició penjada. (En aquest cas eltry/catchho tapa, però el conveni del projecte ésasincron.)
Correcció:
router.post(
'/:id/pagament',
autenticar,
exigirRol('client', 'empleat', 'administrador'),
limitEscriptura,
exigirClauIdempotencia,
validar(esquemaPagament, 'body'),
asincron(async (req, res) => {
// El registrador fill ja porta tracaId, clientId i ruta plantilla.
// Es registren METADADES, mai el cos.
req.log.info({ comandaId: req.params.id, metodePagament: req.dadesValidades.metode }, 'iniciant pagament');
const resultat = await serveis.comandes.pagar(req.params.id, req.dadesValidades, req.usuari);
// Mètrica de negoci amb etiquetes de cardinalitat baixa.
pagamentsCompletats.inc({ metode: req.dadesValidades.metode });
req.log.info({ comandaId: req.params.id }, 'pagament completat');
res.json(resultat);
// Sense try/catch: els errors pugen al gestor central, que ja registra el
// 5xx amb pila completa, retorna error_intern + tracaId i compta la mètrica.
// La latència la mesura metriquesMiddleware per a TOTES les rutes per igual.
})
);Solució 2
| Mètrica | Tipus | Etiquetes (cardinalitat) | Pregunta que respon |
|---|---|---|---|
aroma_subscripcions_actives |
Gauge | periodicitat (2), torrefaccio (3) = 6 |
Quantes subscripcions hi ha vives ara? |
aroma_subscripcions_creades_total |
Comptador | origenClient (~4) |
Quantes altes per dia? Tendència |
aroma_subscripcions_cancellades_total |
Comptador | motiu (~5) |
Quanta fuita hi ha i per què? |
aroma_subscripcions_cobraments_total |
Comptador | resultat (ok/fallada/reintent) = 3 |
Quants cobraments fallen? |
aroma_subscripcions_enviaments_generats_total |
Comptador | resultat (2) |
Es generen els enviaments del cicle mensual? |
aroma_subscripcions_cobrament_duracio_segons |
Histograma | resultat (3) |
Quant triga la passarel·la? |
Mètrica de negoci principal: aroma_subscripcions_actives. És la que reflecteix el valor real de la funcionalitat; si cau, hi ha un problema encara que tota la tècnica estigui verda.
Etiquetes descartades per cardinalitat: clientId (milions), subscripcioId (il·limitats), cafeId (creix amb el catàleg, i amb milers de referències seria problemàtic).
Alerta útil: «la proporció de cobraments de subscripció amb resultat fallada supera el 5 % en una finestra de 30 minuts». Compleix les quatre condicions: és accionable (revisar la passarel·la i els mètodes de pagament caducats), urgent (cada hora són ingressos perduts i clients molestos), documentable en un runbook, i no sorollosa, perquè un percentatge petit de fallades és normal i el llindar és per damunt.
Alerta complementària: «el procés mensual de generació d'enviaments no ha registrat cap increment a aroma_subscripcions_enviaments_generats_total durant la seva finestra d'execució». Detecta la fallada silenciosa més perillosa d'un procés programat: que senzillament no s'executi.
Solució 3
Pas 1 — Mètriques: acotar el problema (2 minuts).
- Gràfic de taxa d'error per ruta: és només
POST /v1/comandeso també d'altres? Si són totes, apunta a alguna cosa transversal (base de dades, desplegament). Si és només aquesta, a la seva lògica o a una dependència seva. - Quan va començar exactament? Correlacionar amb l'historial de desplegaments. Un salt brusc a una hora exacta sol ser un desplegament o un canvi de configuració; una pujada progressiva apunta a esgotament de recursos o creixement de dades.
- Latència: si el p99 va pujar abans que els errors, probablement són timeouts. Si els errors van aparèixer sense canvi de latència, és una fallada lògica (una excepció).
aroma_comandes_creades_total: ha caigut? Confirma l'impacte real en el negoci i dona urgència.- Saturació: memòria, event loop, pool de connexions. Descarta o confirma esgotament de recursos.
Pas 2 — Traces: localitzar on es trenca (3 minuts).
- Filtrar traces de
POST /v1/comandesamb error als últims 15 minuts. - Mirar l'arbre de trams: en quin tram acaba la traça? Resultats possibles i la seva lectura:
- Es talla a
grpc.inventari.ReservarEstoc→ el servei d'inventari falla o va lent. - Es talla a
db.transaccio→ problema de base de dades: bloqueig, disc, migració a mitges. - Acaba ràpid sense arribar a les dependències → excepció a la validació o a la lògica.
- Tots els trams són normals però el total és enorme → contenció o pauses del recol·lector d'escombraries.
- Es talla a
- Comparar amb una traça correcta d'abans de l'incident: la diferència salta a la vista.
Pas 3 — Logs: obtenir el detall (2 minuts).
- Prendre el
tracaIdd'una traça fallida i buscar totes les seves línies. - Llegir la línia de nivell
error: missatge, tipus d'excepció i pila completa. - Comprovar el patró: fallen totes les comandes o només algunes? Filtrar per
clientOauth(només la SPA? només CataBox?), perclientId(un client concret amb dades estranyes?), per nombre de línies de la comanda. - Buscar
warnals minuts previs: consultes lentes, Redis caigut,429. Sovint la causa arrel apareix allà abans que l'error.
Conclusió i acció. Amb els tres passos tens: què falla (la mètrica), on (la traça) i per què (el log). Si la causa és un desplegament recent, es reverteix abans de continuar investigant —restaurar el servei primer, entendre després—. Si és una dependència externa, s'activa el circuit breaker de 04-04 per degradar en comptes de fallar. I l'incident es tanca amb dues coses: una prova de regressió que el reprodueixi (03-08) i, si l'alerta va arribar tard, una alerta nova o un ajust del llindar.
Conclusió
L'observabilitat és la capa que converteix un sistema que funciona en un sistema que es pot operar. Has substituït per fi el console.log de la posició 5 per pino amb logs estructurats en JSON, amb un registrador fill per petició que arrossega el tracaId que portem emetent des de 03-02, la ruta registrada com a plantilla i no com a URI, nivells ben assignats —els 4xx no són errors—, serialitzadors en llista blanca i redacció automàtica de contrasenyes, tokens i dades personals, amb la seva prova i amb la política de retenció que exigeix el RGPD. Has vist com es correlaciona Aroma-Traca-Id d'extrem a extrem, des de la SPA fins al gRPC d'inventari i els webhooks a RàpidEnviaments, i com es tanca el cercle que va obrir 03-07: a l'equip, la pila completa; al client, error_intern i una referència amb la qual suport ho troba tot en deu segons. Has instrumentat l'API amb prom-client seguint els quatre senyals d'or i el mètode RED, amb histogrames els cubells dels quals surten del pressupost de latència de 04-06, mètriques de negoci —comandes creades, estoc esgotat, 429 emesos, encerts de memòria cau— i /metriques protegit i fora de /v1, sabent per què etiquetar amb com_5001 rebenta el sistema. Coneixes les traces distribuïdes, traceparent de W3C i OpenTelemetry, i saps llegir un arbre de trams de POST /v1/comandes per descobrir que el 70 % del temps era on no miraves. I has separat liveness de readiness, definit SLO amb pressupost d'error, i triat què alertar —símptomes, mai CPU— i què mirar el dia del llançament.
Amb això es tanca el mòdul 4. L'API de la Botiga Aroma ja no és només correcta: té criteri de disseny amb antipatrons identificats i linting del contracte; coneix les seves amenaces i les defensa amb helmet, gestió de secrets i controls de RGPD; delega l'accés a tercers amb OAuth 2.0 i OpenID Connect sense veure ni una sola contrasenya; es protegeix de l'abús amb límits per nivell, 429 i Retry-After; deixa entrar la SPA i el tauler sense obrir la porta a ningú més; respon 304 en comptes de repetir-se i tanca la concurrència optimista amb If-Match i el 412; i explica el que li passa en logs, mètriques i traces correlacionades. És una API llesta per a producció.
El que falta ja no és l'API: són les eines que l'envolten i la feina d'equip que la sosté. Al mòdul 5, Eines i Frameworks, deixarem d'escriure codi de servidor per treballar-hi al damunt: Postman per explorar, provar i compartir col·leccions executables (05-01); Swagger i OpenAPI per convertir aquell openapi.yaml que arrosseguem des de 02-08 en documentació viva, generació de clients i validació (05-02); un recorregut comparat pels frameworks populars —Fastify, NestJS, Django REST, Spring Boot, ASP.NET Core— per saber què es guanya i què es perd en triar-ne cadascun (05-03); contractes, mocks i proves automatitzades perquè consumidor i proveïdor no es trenquin mútuament (05-04); integració contínua i desplegament, on Spectral, npm audit, les proves i el readiness que acabem d'escriure es converteixen en una canonada que desplega sense talls (05-05); i les API gateways i portals de desenvolupador, on diversos dels mecanismes d'aquest mòdul —rate limiting, autenticació, memòria cau, mètriques— reapareixen resolts una capa per damunt (05-06).
Curs de REST API: Principis de Disseny i Desenvolupament d'APIs RESTful
Mòdul 1: Introducció a les APIs RESTful
- Què és una API?
- Història i evolució de les APIs
- Fonaments d'HTTP per a APIs
- Principis bàsics de REST
- Model de maduresa de Richardson i HATEOAS
- REST vs. SOAP
- REST davant de GraphQL, gRPC i webhooks
Mòdul 2: Disseny d'APIs RESTful
- Principis de disseny d'APIs RESTful
- Recursos i URIs
- Mètodes HTTP
- Codis d'estat HTTP
- Representacions, capçaleres i negociació de contingut
- Filtratge, ordenació, paginació i cerca
- Versionat d'APIs
- Documentació d'APIs
Mòdul 3: Desenvolupament d'APIs RESTful
- Configuració de l'entorn de desenvolupament
- Creació d'un servidor bàsic
- Gestió de peticions i respostes
- Validació de dades d'entrada
- Persistència i capa d'accés a dades
- Autenticació i autorització
- Gestió d'errors
- Proves i validació
Mòdul 4: Bones Pràctiques i Seguretat
- Bones pràctiques en el disseny d'APIs
- Seguretat en APIs RESTful
- OAuth 2.0 i OpenID Connect a la pràctica
- Rate limiting i throttling
- CORS i polítiques de seguretat
- Memòria cau HTTP i rendiment
- Observabilitat: logs, mètriques i traces
Mòdul 5: Eines i Frameworks
- Postman per a proves d'APIs
- Swagger i OpenAPI per a documentació
- Frameworks populars per a APIs RESTful
- Contractes, mocks i proves automatitzades d'API
- Integració contínua i desplegament
- API gateways i portals de desenvolupador
