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

  1. Monitoratge enfront d'observabilitat
  2. Els tres pilars i quina pregunta respon cadascun
  3. Logs estructurats: per què JSON i no text
  4. pino i el registrador de la Botiga Aroma
  5. Què registrar sempre i què no registrar mai
  6. Redacció automàtica de camps sensibles
  7. El middleware src/middleware/registre.js
  8. Correlació d'extrem a extrem amb Aroma-Traca-Id
  9. Errors 5xx: què es registra enfront de què es retorna
  10. Mètriques: els quatre senyals d'or i el mètode RED
  11. Tipus de mètrica i per què l'histograma
  12. Instrumentar amb prom-client i exposar /metriques
  13. Cardinalitat: per què com_5001 rebenta el sistema
  14. Traces distribuïdes: spans, context i traceparent
  15. Una traça de POST /v1/comandes
  16. Comprovacions de salut: liveness i readiness
  17. Alertes útils, SLO i pressupost d'error
  18. El tauler mínim del dia del llançament
  19. L'stack habitual
  20. Balanç del mòdul 4

  1. 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.

  1. 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:

  1. Una mètrica dispara l'alerta: la taxa d'error de POST /v1/comandes/{id}/pagament ha pujat.
  2. Una traça d'una petició fallida mostra on es trenca: la crida a la passarel·la.
  3. Un log amb el tracaId d'aquella traça dona el detall: el missatge exacte de l'error i el clientId.

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.

  1. Logs estructurats: per què JSON i no text

El que emet avui el nostre middleware:

POST /v1/comandes/com_5001/pagament → 500 (312 ms)

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 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.

  1. 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.

npm install pino pino-http
npm install --save-dev pino-pretty
// 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.

  1. 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.

  1. 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.

  1. El middleware src/middleware/registre.js

Aquí 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);                                            // 16

Per 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 429 no 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 400 i 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.

  1. Correlació d'extrem a extrem amb Aroma-Traca-Id

El 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.

  1. 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)
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.

  1. 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.

  1. 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"}  10000

Els cubells 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.

  1. Instrumentar amb prom-client i exposar /metriques

npm install prom-client
// 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.

  1. Cardinalitat: per què com_5001 rebenta el sistema

La 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
ruta (plantilla) ~24
estat ~8
torrefaccio 3
rol 4
clientOauth ~10
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'.

  1. Traces distribuïdes: spans, context i traceparent

Quan 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.

  1. Una traça de POST /v1/comandes

sequenceDiagram
  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 201 trigaria 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
    }
  });
}

  1. 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.

  1. 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.01

Cada 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).

  1. 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.

  1. 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.

  1. 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:

  1. console.log en comptes del registrador. Sense estructura, sense nivell, sense tracaId, i no arriba al sistema de logs amb format.
  2. Registra el cos complet. El cos d'un pagament pot contenir dades de targeta. És una fallada greu de seguretat i de RGPD.
  3. Cardinalitat explosiva a la mètrica. ruta amb l'identificador real i una etiqueta client amb el clientId: una sèrie per cada comanda i per cada client.
  4. Només compta els èxits. L'inc() és dins del try després de l'operació, així que els errors no es compten i la taxa d'error és sempre zero.
  5. No mesura la latència com a mètrica, només la imprimeix. No hi ha histograma ni percentils.
  6. Retorna error.message al client. Filtra l'interior del sistema: trenca el que es va establir a 03-07 i 04-02.
  7. No fa servir el gestor d'errors central. El format de l'error no compleix el contracte (codi, missatge, detalls, tracaId).
  8. Falta asincron(): un rebuig no capturat deixaria la petició penjada. (En aquest cas el try/catch ho tapa, però el conveni del projecte és asincron.)

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/comandes o 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/comandes amb 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.
  • 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 tracaId d'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?), per clientId (un client concret amb dades estranyes?), per nombre de línies de la comanda.
  • Buscar warn als 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

Mòdul 2: Disseny d'APIs RESTful

Mòdul 3: Desenvolupament d'APIs RESTful

Mòdul 4: Bones Pràctiques i Seguretat

Mòdul 5: Eines i Frameworks

Mòdul 6: Casos d'Estudi i Projectes

© Copyright 2026. Tots els drets reservats