El mòdul 5 va acabar amb una pregunta incòmoda: tenim set serveis desplegats a Kubernetes amb canary i GitOps, però si demà la saga d'una comanda s'encalla, com ho sabrem? Al monòlit la resposta era "mira el log del servidor": un procés, un fitxer, un grep. A TechCorp ara hi ha desenes de pods que arrenquen i moren, una petició travessa tres o quatre serveis i una saga continua viva minuts després que la petició HTTP hagi acabat. Aquesta lliçó construeix les dues primeres potes de l'observabilitat —logs estructurats i mètriques— sobre la llibreria @techcorp/comu-http i els manifestos que ja tenim, i deixa la tercera, les traces, per a 06-02.

Contingut

  1. Els tres pilars de l'observabilitat i per què "mirar el log" ja no n'hi ha prou
  2. Logging estructurat amb pino: ampliar crearLogger
  3. Què no cal registrar: redacció de dades sensibles
  4. Recollida de logs a Kubernetes: Promtail/Alloy i Loki
  5. Consultes LogQL: seguir com-88213 per diversos serveis
  6. Mètriques: tipus, mètode RED i mètode USE
  7. Instrumentació amb prom-client: GET /metrics a la llibreria
  8. Mètriques de negoci: comandes, saga i outbox
  9. Prometheus a Kubernetes i consultes PromQL bàsiques
  10. Dashboards a Grafana i correlació logs ↔ mètriques
  11. Cost i cardinalitat

  1. Els tres pilars de l'observabilitat i per què "mirar el log" ja no n'hi ha prou

Monitorar és comprovar que el sistema fa el que n'esperem (respon POST /v1/comandes? en quant de temps?). Observar és poder respondre preguntes que no havíem previst ("per què la comanda de l'Ana Ruiz porta 20 minuts en ESTOC_RESERVAT?") a partir del que el sistema emet. Es recolza en tres senyals complementaris:

Pilar Què és Pregunta que respon Eina a TechCorp
Logs Esdeveniments discrets amb context (text o JSON) "Què va passar exactament amb aquesta comanda?" pino → stdout → Promtail/Alloy → Loki
Mètriques Sèries temporals numèriques agregades "Quants, com de ràpid, amb quina taxa d'error?" prom-clientPrometheusGrafana
Traces El recorregut d'una petició per diversos serveis "On se n'ha anat el temps? En quin salt va fallar?" OpenTelemetry → Jaeger (06-02)

Al monòlit, log i traça eren gairebé el mateix: una petició era un fil d'execució dins d'un procés. En microserveis canvien tres coses: hi ha molts processos efímers (un kubectl logs només mostra un pod, i els pods d'un rolling de 05-04 desapareixen amb els seus logs: cal treure'ls del contenidor i centralitzar-los); una acció travessa diversos serveis (POST /v1/comandes passa pel gateway, Comandes, Catàleg i Clients, i la saga continua per RabbitMQ a Inventari, Pagaments i Notificacions: res no es pot llegir sense un identificador comú, l'X-Request-Id de 03-01 i l'esdevenimentId de 03-02); i el volum (el catàleg en Black Friday multiplica ×20: llegir logs a mà no escala, i les mètriques resumeixen milions de peticions en uns pocs números).

Regla del mòdul: els logs expliquen el detall, les mètriques expliquen la tendència i les traces expliquen el camí. Els tres s'uneixen per identificadors comuns.

  1. Logging estructurat amb pino: ampliar crearLogger

Un log "de text" (Comanda com-88213 creada per a c-1024) és fàcil de llegir i gairebé impossible de consultar. Un log estructurat és un objecte JSON per línia: cada camp es pot filtrar i agregar. A 04-02 la llibreria ja oferia crearLogger({servei}) amb pino; ara en fixem la forma definitiva:

// @techcorp/comu-http/src/logger.js
const pino = require('pino');
function crearLogger({ servei, versio = process.env.SERVEI_VERSIO, nivell = process.env.LOG_NIVELL || 'info' }) {
  return pino({
    level: nivell,
    base: { servei, versio },                    // camps presents a TOTES les línies
    messageKey: 'missatge',
    timestamp: pino.stdTimeFunctions.isoTime,    // "time":"2026-08-15T10:21:04.512Z"
    formatters: {
      level: (etiqueta) => ({ nivell: etiqueta }) // "nivell":"info" en comptes de "level":30
    },
    redact: {                                    // vegeu l'apartat 3
      paths: ['req.headers.authorization', 'req.headers.cookie', '*.targeta', '*.email', '*.password'],
      censor: '[REDACTAT]'
    }
  });
}
module.exports = { crearLogger };

Línia a línia:

  • level: nivell: el llindar. Ve de LOG_NIVELL (04-03), info en producció i debug en local. Per sota del llindar pino ni tan sols serialitza l'objecte: abaixar el nivell no costa CPU.
  • base: camps fixos que s'afegeixen a cada línia. servei és obligatori (servei-comandes); versio (l'etiqueta de la imatge, 05-03) permet comparar el canary amb la versió estable.
  • messageKey: 'missatge' i formatters.level: reanomenem les claus angleses perquè tot el JSON estigui en el mateix idioma que els nostres identificadors; el timestamp en ISO l'entenen Loki i Grafana sense configuració; redact el veurem a l'apartat 3.

Els nivells i quan fer-los servir:

Nivell Ús a TechCorp Exemple
fatal El procés no pot continuar Config invàlida en arrencar (zod, 04-03)
error Fallada no esperada que requereix atenció Excepció no controlada, missatge a la DLQ
warn Anomalia tolerada Reintent a Catàleg, nack amb reencuament
info Fites de negoci i cicle de vida Comanda creada, esdeveniment publicat, servidor arrencat
debug Detall per a desenvolupament Cos de la petició a Catàleg, SQL executat

Els camps estàndard que vam acordar entre els quatre equips, perquè les consultes de Loki siguin iguals a tots els serveis:

Camp On apareix Font
servei, versio, nivell, time, missatge Sempre base i pino
requestId Tot el que passa dins d'una petició HTTP req.id de middlewareRequestId() (04-02)
comandaId Quan l'operació toca una comanda Cas d'ús / consumidor
esdevenimentId, tipusEsdeveniment, cua En consumidors de RabbitMQ Capçaleres del missatge (03-02)
err En error/warn Serialitzador estàndard de pino (message, stack, codi d'ErrorNegoci)

Per no repetir aquests camps a cada crida fem servir loggers fills (logger.child(...)). Així queda el cas d'ús de 04-04:

// servei-comandes/src/casosUs/crearComanda.js (fragment)
async function executar(dades, { requestId }) {
  const log = logger.child({ requestId, clientId: dades.clientId });
  log.info({ linies: dades.linies.length }, 'creant comanda');
  const comanda = await repositori.desarAmbOutbox(/* ... */);
  log.info({ comandaId: comanda.id, total: comanda.total }, 'comanda creada');
  return comanda;
}

Al consumidor de la saga (missatgeria/consumidorSaga.js) el fill es crea a partir del missatge: logger.child({ esdevenimentId: esdeveniment.id, tipusEsdeveniment: esdeveniment.tipus, comandaId: esdeveniment.dades.comandaId, cua: 'comandes.saga' }). Sortida real d'una línia (una per fila, sense salts):

{"nivell":"info","time":"2026-08-15T10:21:04.512Z","servei":"servei-comandes","versio":"1.4.2","requestId":"req-01J5Q7X2N9C4M8Z0K1T3V6W8Y","clientId":"c-1024","comandaId":"com-88213","total":79.7,"missatge":"comanda creada"}

Logs d'accés amb pino-http. Una línia per petició HTTP, generada pel middleware i no a mà. Va a la plantilla de la llibreria, just després de middlewareRequestId() perquè faci servir el mateix req.id:

// @techcorp/comu-http/src/middlewareLogAcces.js
const pinoHttp = require('pino-http');

function middlewareLogAcces({ logger }) {
  return pinoHttp({
    logger,
    genReqId: (req) => req.id,                        // reutilitza el req-<ulid> de middlewareRequestId
    autoLogging: { ignore: (req) => req.url.startsWith('/health') || req.url === '/metrics' },
    customLogLevel: (req, res, err) => (err || res.statusCode >= 500 ? 'error' : res.statusCode >= 400 ? 'warn' : 'info'),
    serializers: {
      req: (req) => ({ metode: req.method, ruta: req.url }),
      res: (res) => ({ codi: res.statusCode })
    },
    customProps: (req) => ({ requestId: req.id })
  });
}
  • genReqId: si no ho indiquéssim, pino-http generaria el seu propi id i perdríem la correlació amb X-Request-Id; autoLogging.ignore evita registrar les sondes de Kubernetes (05-02), que criden /health/* cada 5-10 s.
  • customLogLevel: un 5xx es veu com a error sense que ningú se n'hagi de recordar; els serializers redueixen req/res a mètode, ruta i codi (pino-http hi afegeix, a més, responseTime en ms).

  1. Què no cal registrar: redacció de dades sensibles

Un log centralitzat el llegeix molta gent i es conserva setmanes. El que hi entra s'ha de poder llegir sense permisos especials. A TechCorp queda prohibit registrar: dades personals més enllà de l'identificador (clientId sí; nom, correu o adreça de l'Ana Ruiz, no); dades de pagament (número de targeta, CVV, tokens del PSP); credencials (capçalera Authorization, cookies, contrasenyes, RABBITMQ_URL completa, que conté usuari i contrasenya); i cossos complets de peticions en info (en debug només si passen per la redacció).

L'opció redact de pino ho fa per nosaltres: qualsevol propietat que coincideixi amb paths se substitueix per [REDACTAT] abans de serialitzar. *.targeta significa "una clau targeta en qualsevol objecte de primer nivell". Un exemple:

log.info({ pagament: { targeta: '4111111111111111', import: 79.7 } }, 'cobrament sollicitat');
// {"...","pagament":{"targeta":"[REDACTAT]","import":79.7},"missatge":"cobrament sollicitat"}

La redacció és una xarxa de seguretat, no una llicència: la regla és no passar aquestes dades al logger. El tractament complet (xifratge, secrets, auditoria) és de 07-03; aquí n'hi ha prou que el logger no les filtri.

  1. Recollida de logs a Kubernetes: Promtail/Alloy i Loki

Els serveis escriuen a stdout (decisió de 05-01: dotze factors, sense fitxers al contenidor). El kubelet desa aquesta sortida a /var/log/containers/*.log del node, i un agent desplegat com a DaemonSet (un pod per node) la llegeix, hi afegeix les etiquetes de Kubernetes i l'envia al magatzem central. A TechCorp el magatzem és Loki (mateixa família que Grafana i Prometheus, indexa només etiquetes i desa el text comprimit: barat i suficient per al nostre volum) i l'agent és Promtail o el seu successor Grafana Alloy.

Fragment de la configuració de Promtail (Helm values.yaml, instal·lat per l'equip de Plataforma al namespace observabilitat):

config:
  clients:
    - url: http://loki.observabilitat.svc.cluster.local:3100/loki/api/v1/push
  snippets:
    pipelineStages:
      - cri: {}                              # parseja el format de log del contenidor (timestamp, stream, text)
      - json:                                # extreu camps del JSON de pino
          expressions:
            nivell: nivell
            servei: servei
      - labels:                              # només aquests dos es converteixen en etiquetes indexades
          nivell:
          servei:
    extraRelabelConfigs:
      - source_labels: [__meta_kubernetes_pod_label_app]   # label app del Deployment (05-02)
        target_label: app
      - source_labels: [__meta_kubernetes_pod_label_version]
        target_label: version
  • clients.url: on empeny les línies; Loki és un Service més del clúster. pipelineStages: cri separa l'embolcall del runtime; json extreu nivell i servei; labels promociona només aquests a etiquetes; la resta (requestId, comandaId) es queda al text i es filtra en consulta.
  • extraRelabelConfigs: converteix les labels app i version del pod (les mateixes del Deployment de 05-02) en etiquetes de Loki. Per això consultem per {app="servei-comandes"}.

Per què no promocionar requestId a etiqueta? Perquè cada valor diferent crea una sèrie nova i Loki es degrada amb milions de sèries: etiquetes per al que té pocs valors (app, version, nivell, namespace), text per al que en té infinits (requestId, comandaId). La mateixa regla reapareixerà amb Prometheus a l'apartat 11.

Alternatives equivalents, per reconèixer-les quan apareguin:

Pila Agent Magatzem Visor Quan triar-la
PLG (triada) Promtail / Alloy Loki Grafana Ja fem servir Grafana; volum mitjà; cost baix
EFK / ELK Fluent Bit / Fluentd / Logstash Elasticsearch / OpenSearch Kibana Cerca de text complet intensiva; equips que ja operen Elastic
SaaS (Datadog, Cloud Logging…) Agent propi Gestionat Propi Sense equip de Plataforma; s'accepta el cost per GB

  1. Consultes LogQL: seguir com-88213 per diversos serveis

LogQL té dues parts: un selector d'etiquetes entre claus i una canonada de filtres. Les consultes que l'equip del Luis fa servir cada dia:

# (1) tot el que Comandes ha escrit sobre una comanda
{app="servei-comandes"} |= "com-88213"

# (2) una petició a través de TOTS els serveis del namespace
{namespace="techcorp"} | json | requestId="req-01J5Q7X2N9C4M8Z0K1T3V6W8Y"

# (3) tots els errors de tots els serveis, compactes
{app=~"servei-.*", nivell="error"} | json | line_format "{{.servei}} {{.missatge}} {{.err.message}}"

# (4) Inventari va rebre l'esdeveniment?
{app="servei-inventari"} | json | tipusEsdeveniment="comanda.creada" | comandaId="com-88213"

# (5) errors per servei en finestres de 5 minuts (mètrica derivada de logs)
sum by (app) (count_over_time({namespace="techcorp", nivell="error"}[5m]))
  1. |= és "conté el text": la creació, l'esdeveniment a l'outbox, cada pas de la saga.
  2. | json parseja la línia i deixa filtrar per camp exacte; el resultat són les línies de gateway, Comandes, Catàleg i Clients en ordre temporal. És el motiu pel qual 03-01 va insistir a propagar X-Request-Id: sense això, aquesta consulta no existeix.
  3. =~ és una expressió regular sobre l'etiqueta; line_format redueix cada línia al que interessa.
  4. Si torna buit i Comandes mostra "esdeveniment publicat", el problema és entre el relay de l'outbox i la cua inventari.comandes (03-02).
  5. LogQL també agrega; útil per explorar, però per al que es vigila de manera permanent convé una mètrica de debò, que és el que ve a continuació.

  1. Mètriques: tipus, mètode RED i mètode USE

Una mètrica és un número amb nom i etiquetes, mostrejat periòdicament (Prometheus rascascrape— cada servei cada 15-30 s). Prometheus defineix quatre tipus:

Tipus Què és Només puja Exemple TechCorp Com es consulta
Counter Comptador acumulat http_requests_total, comandes_creades_total Amb rate(): interessa la velocitat, no el valor
Gauge Valor instantani que puja i baixa No outbox_pendents, missatges en cua Directament, o max_over_time
Histogram Distribució en buckets acumulats + suma + compte http_request_duration_seconds, saga_durada_segons histogram_quantile() per a percentils; s'agrega entre pods
Summary Percentils calculats al client (no el fem servir) No es pot agregar entre pods: per això preferim histogrames

Dos mètodes ens diuen què mesurar sense inventar:

  • RED, per a serveis (el que fa l'equip de Comandes): Rate (peticions/s), Errors (proporció de fallades), Duration (latència, amb percentils). Amb aquestes tres per servei i per ruta es veu el 90 % dels problemes.
  • USE, per a recursos (el que mira l'equip de Plataforma): Utilization (% de CPU, memòria, disc), Saturation (cua d'espera: missatges a RabbitMQ, connexions esperant al pool de pg), Errors (errors del recurs). Kubernetes ja exposa CPU i memòria per pod a través de cAdvisor i kube-state-metrics; nosaltres hi aportem les de RabbitMQ i els pools.

  1. Instrumentació amb prom-client: GET /metrics a la llibreria

prom-client és la llibreria oficial de Prometheus per a Node.js. Igual que vam fer amb el logger, la instrumentació HTTP viu un sol cop a @techcorp/comu-http i tots els serveis la reben:

// @techcorp/comu-http/src/metriques.js
const client = require('prom-client');
function crearMetriques({ servei }) {
  const registre = new client.Registry();
  registre.setDefaultLabels({ servei });
  client.collectDefaultMetrics({ register: registre });   // CPU, memòria, event loop lag, GC de Node
  const peticions = new client.Counter({
    name: 'http_requests_total',
    help: 'Peticions HTTP rebudes',
    labelNames: ['metode', 'ruta', 'codi'],
    registers: [registre]
  });
  const durada = new client.Histogram({
    name: 'http_request_duration_seconds',
    help: 'Durada de les peticions HTTP en segons',
    labelNames: ['metode', 'ruta', 'codi'],
    buckets: [0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2, 5],
    registers: [registre]
  });
  function middleware(req, res, next) {
    const fi = durada.startTimer();
    res.on('finish', () => {
      const ruta = req.route ? req.baseUrl + req.route.path : 'desconeguda';   // "/v1/comandes/:id", NO "/v1/comandes/com-88213"
      const etiquetes = { metode: req.method, ruta, codi: String(res.statusCode) };
      peticions.inc(etiquetes);
      fi(etiquetes);
    });
    next();
  }
  async function gestorMetrics(req, res) {
    res.set('Content-Type', registre.contentType);
    res.end(await registre.metrics());
  }
  return { registre, middleware, gestorMetrics };
}
module.exports = { crearMetriques };

Explicació:

  • Un Registry propi per servei, amb servei com a etiqueta per defecte: totes les sèries porten servei="servei-comandes" sense repetir-ho.
  • collectDefaultMetrics afegeix gratis process_cpu_seconds_total, nodejs_heap_size_used_bytes, nodejs_eventloop_lag_seconds… Aquesta última és or a Node: si puja, el fil únic està bloquejat (06-04).
  • http_requests_total{metode, ruta, codi}: el comptador RED. L'etiqueta ruta fa servir la plantilla d'Express (/v1/comandes/:id), mai la URL real, o crearíem una sèrie per comanda (apartat 11). Si cap ruta no ha coincidit (404), etiquetem desconeguda.
  • http_request_duration_seconds: histograma amb buckets de 10 ms a 5 s. El bucket de 2 s coincideix amb TIMEOUT_HTTP_MS=2000 (03-01): així veiem quantes peticions freguen el límit.
  • startTimer() retorna una funció que, en cridar-la amb les etiquetes, observa els segons transcorreguts; es crida a finish, quan la resposta ja s'ha enviat. gestorMetrics serveix el text en format Prometheus.

A crearApp de la plantilla (04-02) l'ordre queda: middlewareRequestId()middlewareLogAcces({logger})metriques.middleware → rutes → middlewareErrors({logger}), i una ruta app.get('/metrics', metriques.gestorMetrics). Sortida de curl localhost:3002/metrics (retallada):

http_requests_total{metode="POST",ruta="/v1/comandes",codi="202",servei="servei-comandes"} 1287
http_requests_total{metode="POST",ruta="/v1/comandes",codi="503",servei="servei-comandes"} 4
http_request_duration_seconds_bucket{metode="POST",ruta="/v1/comandes",codi="202",le="0.1",servei="servei-comandes"} 812
http_request_duration_seconds_bucket{metode="POST",ruta="/v1/comandes",codi="202",le="0.25",servei="servei-comandes"} 1240
http_request_duration_seconds_bucket{metode="POST",ruta="/v1/comandes",codi="202",le="+Inf",servei="servei-comandes"} 1287
http_request_duration_seconds_sum{...} 142.8
http_request_duration_seconds_count{...} 1287

Cada _bucket{le="0.25"} compta les peticions que van trigar ≤ 0,25 s (acumulat): 1.240 de 1.287. D'aquí surt el p95 a l'apartat 9.

  1. Mètriques de negoci: comandes, saga i outbox

Les mètriques RED diuen que el servei respon; les de negoci diuen que la botiga ven. Es declaren al mateix servei contra el mateix registre:

// servei-comandes/src/metriques.js
const client = require('prom-client');
function crearMetriquesComandes({ registre, bd }) {
  const creades = new client.Counter({ name: 'comandes_creades_total', help: 'Comandes creades', registers: [registre] });
  const cancellades = new client.Counter({
    name: 'comandes_cancellades_total', help: 'Comandes cancel·lades per motiu',
    labelNames: ['motiu'], registers: [registre]                       // SENSE_ESTOC | PAGAMENT_REBUTJAT | TIMEOUT_PAGAMENT
  });
  const sagaDurada = new client.Histogram({
    name: 'saga_durada_segons', help: 'Segons des de comanda.creada fins a comanda.confirmada',
    buckets: [1, 2, 5, 10, 30, 60, 120, 300, 900], registers: [registre]  // 900 = expira_en de la reserva
  });
  new client.Gauge({
    name: 'outbox_pendents', help: 'Files de l\'outbox sense publicar',
    registers: [registre],
    async collect() {                                                    // s'executa a cada scrape
      const { rows } = await bd.query('SELECT count(*)::int AS n FROM outbox WHERE publicat_en IS NULL');
      this.set(rows[0].n);
    }
  });
  return { creades, cancellades, sagaDurada };
}
  • comandes_creades_total s'incrementa al cas d'ús després de confirmar la transacció; comandes_cancellades_total{motiu} al consumidor de la saga en processar comanda.cancellada (només tres valors de motiu: cardinalitat controlada).
  • saga_durada_segons: en tancar la comanda, sagaDurada.observe((ara - comanda.creat_en) / 1000); els buckets arriben fins a 900 s perquè una reserva expira als 15 minuts (02-05); a 06-05 serà la base de l'SLI "confirmada en < 60 s". outbox_pendents amb collect() consulta la taula quan Prometheus rasca: és la mètrica que delata un relay caigut (les comandes es creen però els seus esdeveniments no surten).

RabbitMQ es mesura amb el seu plugin rabbitmq_prometheus (port 15692), que el chart de Helm de 05-02 activa amb metrics.enabled: true. Exposa, entre d'altres, rabbitmq_queue_messages_ready{queue="inventari.comandes"} (missatges esperant consumidor) i la mateixa per a comandes.saga.dlq. Una DLQ amb missatges és sempre una anomalia (03-02), i a 06-05 serà una alerta.

  1. Prometheus a Kubernetes i consultes PromQL bàsiques

Prometheus descobreix què ha de rascar mitjançant l'API de Kubernetes. Amb l'operador (kube-prometheus-stack, instal·lat per Plataforma) es declara un ServiceMonitor per servei, al costat del Service de 05-02:

apiVersion: monitoring.coreos.com/v1
kind: ServiceMonitor
metadata:
  name: servei-comandes
  namespace: techcorp
  labels:
    release: kube-prometheus-stack     # l'operador només mira ServiceMonitors amb aquesta label
spec:
  selector:
    matchLabels:
      app: servei-comandes             # coincideix amb les labels del Service
  endpoints:
    - port: http                       # nom del port al Service (3002)
      path: /metrics
      interval: 30s

Sense operador, l'alternativa són anotacions al Pod (prometheus.io/scrape: "true", prometheus.io/port: "3002", prometheus.io/path: "/metrics") que la configuració clàssica de kubernetes_sd_configs interpreta. El ServiceMonitor va a la base de Kustomize de cada servei; els serveis nous l'hereten de la plantilla.

Les consultes PromQL que responen a les preguntes RED (totes executables a Grafana → Explore):

# (1) Rate: peticions per segon per servei
sum by (servei) (rate(http_requests_total[5m]))

# (2) Errors: proporció de 5xx (0,002 = 0,2 %)
sum by (servei) (rate(http_requests_total{codi=~"5.."}[5m]))
  / sum by (servei) (rate(http_requests_total[5m]))

# (3) Duration: p95 per servei i ruta
histogram_quantile(0.95, sum by (le, servei, ruta) (rate(http_request_duration_seconds_bucket[5m])))

# (4) condició: outbox encallat
outbox_pendents > 100

# (5) cancel·lacions per motiu a l'última hora
sum by (motiu) (increase(comandes_cancellades_total[1h]))

# (6) qualsevol DLQ amb missatges
rabbitmq_queue_messages_ready{queue=~".*\\.dlq"} > 0
  1. rate calcula el pendent del comptador a la finestra; 5 m suavitza el soroll. Mai no es grafica un comptador en brut.
  2. El numerador filtra per regex de codi, el denominador és el total; tots dos agregats per la mateixa etiqueta perquè la divisió encaixi.
  3. S'agrega la taxa de cada bucket (le s'ha de conservar) i histogram_quantile interpola el percentil. Es pot agregar entre pods perquè els buckets són sumables; amb un summary no podríem.
  4. Retorna només les sèries el valor de les quals supera 100: buit si tot va bé. És la forma d'una condició d'alerta, que 06-05 convertirà en PrometheusRule.
  5. increase és rate × finestra: dona unitats, no unitats/s.
  6. Una DLQ amb missatges és sempre una anomalia (03-02).

  1. Dashboards a Grafana i correlació logs ↔ mètriques

Grafana llegeix Prometheus i Loki com a datasources. Convenim dos dashboards, versionats com a JSON a techcorp/plataforma/observabilitat/dashboards/ i carregats per un ConfigMap amb l'etiqueta grafana_dashboard: "1" (el sidecar del chart els importa):

Dashboard "RED per servei" amb una variable $servei (valors de label_values(http_requests_total, servei)) i quatre panells: peticions/s, % de 5xx, p50/p95/p99, i CPU/memòria del pod (USE) per correlacionar. L'essencial del JSON d'un panell:

{
  "title": "Latència p95 per ruta",
  "type": "timeseries",
  "datasource": { "type": "prometheus", "uid": "prometheus" },
  "targets": [{
    "expr": "histogram_quantile(0.95, sum by (le, ruta) (rate(http_request_duration_seconds_bucket{servei=\"$servei\"}[5m])))",
    "legendFormat": "{{ruta}}"
  }],
  "fieldConfig": { "defaults": { "unit": "s", "thresholds": { "steps": [{ "color": "green" }, { "color": "red", "value": 0.3 }] } } }
}

expr és la consulta de l'apartat 9 amb la variable; legendFormat anomena cada línia per ruta; thresholds pinta de vermell per sobre de 300 ms, l'objectiu que es formalitzarà a 06-05.

Dashboard "Saga de comandes" (equip del Luis): comandes creades/h, cancel·lades per motiu (barres apilades), p95 de saga_durada_segons, outbox_pendents, missatges llestos a inventari.comandes/pagaments.estoc/comandes.saga i les seves DLQ, i un panell d'estat amb la consulta SQL directa a PostgreSQL (SELECT estat, count(*) FROM comandes WHERE creat_en > now() - interval '1 hour' GROUP BY estat), que mostra quantes comandes hi ha a cada estat (PENDENT, ESTOC_RESERVAT, PAGADA, CONFIRMADA, CANCELLADA). Una pila creixent a ESTOC_RESERVAT és la signatura de "Pagaments no respon".

Correlació. Mètriques i logs comparteixen servei/app i version (labels de Kubernetes) i el temps. El flux típic: el panell d'errors puja a les 10:21 → clic al punt → "Explore" amb {app="servei-comandes", nivell="error"} a la mateixa finestra → apareix la línia amb requestId i err.codi=DEPENDENCIA_NO_DISPONIBLE → segona consulta per aquest requestId a tot el namespace → es veu que Catàleg va retornar 503. Grafana permet configurar aquest salt (mètrica → logs) com a data link del panell; el salt de logs a traces (trace_id) l'afegim a 06-02.

  1. Cost i cardinalitat

Cada combinació diferent d'etiquetes és una sèrie que Prometheus guarda en memòria. http_requests_total amb 6 mètodes × 15 rutes × 8 codis × 20 pods són 14.400 sèries: bé. Si algú afegeix comandaId com a etiqueta, amb 3.000 comandes/dia són 3.000 sèries noves cada dia per pod: en un mes Prometheus es queda sense memòria. Regles:

  • Etiquetes amb pocs valors coneguts (servei, ruta plantilla, codi, motiu, cua). Mai identificadors (comandaId, clientId, requestId, esdevenimentId), URLs completes ni cossos.
  • Els identificadors van al log (text, no etiqueta de Loki) i a la traça (06-02). Aquest repartiment és la raó que els tres senyals existeixin.
  • Vigilar el cost: count({__name__=~".+"}) a Prometheus dona el nombre total de sèries; el volum de Loki es controla amb retenció (30 dies a TechCorp), no registrant /health i amb LOG_NIVELL=info per defecte (debug en producció multiplica el volum per 10-50 i s'activa només puntualment amb un canvi de ConfigMap).

Errors Comuns i Consells

  • Concatenar al missatge el que hauria de ser un camp (log.info('comanda ' + id + ' creada')). Després no es pot filtrar per comandaId. El missatge és constant; el context va a l'objecte.
  • Perdre el requestId pel camí. Si un servei genera el seu en comptes de propagar el rebut, la consulta creuada de LogQL es talla allà. middlewareRequestId() reutilitza l'entrant; els clients HTTP (04-04) el reenvien; els esdeveniments porten esdevenimentId i, a més, el requestId d'origen a les capçaleres.
  • Registrar les sondes i /metrics. Milers de línies inútils al dia per pod.
  • URLs reals com a etiqueta ruta. Explosió de cardinalitat. Fer servir la plantilla d'Express i desconeguda per als 404.
  • Consell: en desenvolupament, LOG_NIVELL=debug i node src/servidor.js | npx pino-pretty per llegir els logs de manera llegible sense canviar el format de producció.

Exercicis

Exercici 1: consumidor d'Inventari amb logs correlacionats

El consumidor d'inventari.comandes rep comanda.creada i reserva estoc. Escriu el fragment que crea el logger fill amb els camps estàndard de l'apartat 2 i emet: un info en rebre, un warn si la reserva falla per manca d'estoc (amb producteId i sollicitat/disponible), i un error si falla la base de dades. Escriu després la consulta LogQL que mostri només les manques d'estoc del producte p-501 a l'última hora.

Exercici 2: detectar el relay caigut

La Marta pregunta: "si el relay de l'outbox de Comandes mor però el pod continua viu, quin panell ho mostraria i quina consulta ho demostra?". Respon amb la mètrica, la consulta PromQL i la consulta LogQL que confirmaria la causa.

Solucions

Exercici 1

async function processarComandaCreada(esdeveniment, { logger, reserves }) {
  const log = logger.child({ esdevenimentId: esdeveniment.id, tipusEsdeveniment: esdeveniment.tipus, comandaId: esdeveniment.dades.comandaId,
    cua: 'inventari.comandes', requestId: esdeveniment.capcaleres?.requestId });
  log.info({ linies: esdeveniment.dades.linies.length }, 'esdeveniment rebut');
  try {
    const resultat = await reserves.reservar(esdeveniment.dades);
    if (!resultat.ok) {
      log.warn({ producteId: resultat.producteId, sollicitat: resultat.sollicitat, disponible: resultat.disponible }, 'estoc insuficient');
      return publicar('estoc.rebutjat', /* ... */);
    }
    log.info({ reservaId: resultat.reservaId }, 'estoc reservat');
  } catch (err) {
    log.error({ err }, 'error en reservar estoc');
    throw err;                       // el consumidor farà nack (03-02)
  }
}
{app="servei-inventari", nivell="warn"} | json | missatge="estoc insuficient" | producteId="p-501"

(El selector de temps "última hora" es tria a Grafana; a la consulta no cal.)

Exercici 2

La mètrica és outbox_pendents (gauge amb collect()): continua creixent perquè POST /v1/comandes continua inserint files i ningú no les publica. Consulta: outbox_pendents{servei="servei-comandes"} > 100 (o el seu pendent: deriv(outbox_pendents[10m]) > 0). Complementària: rate(comandes_creades_total[5m]) > 0 i alhora rabbitmq_queue_messages_ready{queue="inventari.comandes"} == 0 durant minuts. Al dashboard de la saga es veuria el panell d'outbox_pendents pujant i el d'estat amb totes les comandes noves en PENDENT. Causa a Loki: {app="servei-comandes"} | json | missatge=~"relay.*" mostraria l'últim "lot publicat" fa massa temps o un error del canal de RabbitMQ; si el relay va morir sense registrar res, l'absència de línies és la pista. A 06-05 això serà una alerta.

Conclusió

Hem donat a TechCorp els seus dos primers senyals d'observabilitat. Els logs són JSON estructurat amb pino, amb camps comuns (servei, versio, nivell, requestId, comandaId, esdevenimentId), redacció de dades sensibles i una línia d'accés per petició via pino-http; surten per stdout, Promtail/Alloy els etiqueta amb app/version i Loki els desa, de manera que {namespace="techcorp"} | json | requestId="..." reconstrueix una petició a través de diversos serveis. Les mètriques viuen a @techcorp/comu-http (http_requests_total, http_request_duration_seconds a GET /metrics) i a cada servei (comandes_creades_total, comandes_cancellades_total{motiu}, saga_durada_segons, outbox_pendents), Prometheus les rasca mitjançant ServiceMonitor i Grafana les mostra als dashboards RED i de la saga; el preu a pagar és la disciplina de cardinalitat: identificadors als logs, mai a les etiquetes. El que encara no veiem és el camí: sabem que POST /v1/comandes triga 250 ms al p95, però no quant d'aquest temps se'n va anar a Catàleg, a Clients o a PostgreSQL, ni com unir la petició HTTP amb la saga que continua per RabbitMQ. Aquest és el tercer senyal, les traces distribuïdes amb OpenTelemetry, i és la lliçó següent.

Curs de Microserveis

Mòdul 1: Introducció als Microserveis

Mòdul 2: Disseny de Microserveis

Mòdul 3: Comunicació entre Microserveis

Mòdul 4: Implementació de Microserveis

Mòdul 5: Desplegament i Orquestració

Mòdul 6: Monitoratge i Manteniment

Mòdul 7: Seguretat en Microserveis

Mòdul 8: Casos d'Estudi i Exemples Pràctics

© Copyright 2026. Tots els drets reservats