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
- Els tres pilars de l'observabilitat i per què "mirar el log" ja no n'hi ha prou
- Logging estructurat amb pino: ampliar
crearLogger - Què no cal registrar: redacció de dades sensibles
- Recollida de logs a Kubernetes: Promtail/Alloy i Loki
- Consultes LogQL: seguir
com-88213per diversos serveis - Mètriques: tipus, mètode RED i mètode USE
- Instrumentació amb
prom-client:GET /metricsa la llibreria - Mètriques de negoci: comandes, saga i outbox
- Prometheus a Kubernetes i consultes PromQL bàsiques
- Dashboards a Grafana i correlació logs ↔ mètriques
- Cost i cardinalitat
- 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-client → Prometheus → Grafana |
| 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.
- Logging estructurat amb pino: ampliar
crearLogger
crearLoggerUn 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 deLOG_NIVELL(04-03),infoen producció idebugen 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'iformatters.level: reanomenem les claus angleses perquè tot el JSON estigui en el mateix idioma que els nostres identificadors; eltimestampen ISO l'entenen Loki i Grafana sense configuració;redactel 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-httpgeneraria el seu propi id i perdríem la correlació ambX-Request-Id;autoLogging.ignoreevita registrar les sondes de Kubernetes (05-02), que criden/health/*cada 5-10 s.customLogLevel: un 5xx es veu com aerrorsense que ningú se n'hagi de recordar; elsserializersredueixenreq/resa mètode, ruta i codi (pino-httphi afegeix, a més,responseTimeen ms).
- 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.
- 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: versionclients.url: on empeny les línies; Loki és unServicemés del clúster.pipelineStages:crisepara l'embolcall del runtime;jsonextreunivelliservei;labelspromociona només aquests a etiquetes; la resta (requestId,comandaId) es queda al text i es filtra en consulta.extraRelabelConfigs: converteix les labelsappiversiondel pod (les mateixes delDeploymentde 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 |
- Consultes LogQL: seguir
com-88213 per diversos serveis
com-88213 per diversos serveisLogQL 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]))|=és "conté el text": la creació, l'esdeveniment a l'outbox, cada pas de la saga.| jsonparseja 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 propagarX-Request-Id: sense això, aquesta consulta no existeix.=~és una expressió regular sobre l'etiqueta;line_formatredueix cada línia al que interessa.- Si torna buit i Comandes mostra "esdeveniment publicat", el problema és entre el relay de l'outbox i la cua
inventari.comandes(03-02). - 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ó.
- 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 rasca —scrape— cada servei cada 15-30 s). Prometheus defineix quatre tipus:
| Tipus | Què és | Només puja | Exemple TechCorp | Com es consulta |
|---|---|---|---|---|
| Counter | Comptador acumulat | Sí | 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 | Sí | http_request_duration_seconds, saga_durada_segons |
histogram_quantile() per a percentils; s'agrega entre pods |
| Summary | Percentils calculats al client | Sí | (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 ikube-state-metrics; nosaltres hi aportem les de RabbitMQ i els pools.
- Instrumentació amb
prom-client: GET /metrics a la llibreria
prom-client: GET /metrics a la llibreriaprom-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
Registrypropi per servei, ambserveicom a etiqueta per defecte: totes les sèries portenservei="servei-comandes"sense repetir-ho. collectDefaultMetricsafegeix gratisprocess_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'etiquetarutafa 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), etiquetemdesconeguda.http_request_duration_seconds: histograma amb buckets de 10 ms a 5 s. El bucket de 2 s coincideix ambTIMEOUT_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 afinish, quan la resposta ja s'ha enviat.gestorMetricsserveix 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{...} 1287Cada _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.
- 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_totals'incrementa al cas d'ús després de confirmar la transacció;comandes_cancellades_total{motiu}al consumidor de la saga en processarcomanda.cancellada(només tres valors demotiu: 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_pendentsambcollect()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.
- 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: 30sSense 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"} > 0ratecalcula el pendent del comptador a la finestra; 5 m suavitza el soroll. Mai no es grafica un comptador en brut.- El numerador filtra per regex de codi, el denominador és el total; tots dos agregats per la mateixa etiqueta perquè la divisió encaixi.
- S'agrega la taxa de cada bucket (
les'ha de conservar) ihistogram_quantileinterpola el percentil. Es pot agregar entre pods perquè els buckets són sumables; amb un summary no podríem. - 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. increaseésrate× finestra: dona unitats, no unitats/s.- Una DLQ amb missatges és sempre una anomalia (03-02).
- 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.
- 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,rutaplantilla,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/healthi ambLOG_NIVELL=infoper defecte (debugen producció multiplica el volum per 10-50 i s'activa només puntualment amb un canvi deConfigMap).
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 percomandaId. El missatge és constant; el context va a l'objecte. - Perdre el
requestIdpel 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 portenesdevenimentIdi, a més, elrequestIdd'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 idesconegudaper als 404. - Consell: en desenvolupament,
LOG_NIVELL=debuginode src/servidor.js | npx pino-prettyper 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)
}
}(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
- Conceptes Bàsics de Microserveis
- Avantatges i Desavantatges dels Microserveis
- Comparació amb l'Arquitectura Monolítica
- Quan Adoptar Microserveis: Criteris de Decisió
- El Cas Pràctic del Curs: la Botiga Online de TechCorp
Mòdul 2: Disseny de Microserveis
- Principis de Disseny de Microserveis
- Descomposició d'Aplicacions Monolítiques
- Definició de Bounded Contexts
- Gestió de Dades: una Base de Dades per Servei
- Consistència Distribuïda: Sagues, CQRS i Event Sourcing
Mòdul 3: Comunicació entre Microserveis
- APIs RESTful
- Missatgeria Asíncrona
- Protocols de Comunicació: gRPC, GraphQL
- API Gateway i Backend for Frontend
- Descobriment de Serveis i Balanceig de Càrrega
- Contractes i Versionat d'APIs
Mòdul 4: Implementació de Microserveis
- Elecció de Tecnologies i Eines
- Desenvolupament d'un Microservei Simple
- Gestió de Configuració
- Integració Pràctica: Consumir APIs i Publicar Esdeveniments
- Proves en Microserveis: Unitàries, d'Integració i de Contracte
Mòdul 5: Desplegament i Orquestració
- Contenidors i Docker
- Orquestració amb Kubernetes
- CI/CD per a Microserveis
- Estratègies de Desplegament: Rolling, Blue-Green i Canary
- Service Mesh: Istio i Linkerd
Mòdul 6: Monitoratge i Manteniment
- Monitoratge i Logging
- Traçabilitat Distribuïda amb OpenTelemetry
- Gestió d'Errors i Recuperació
- Escalabilitat i Rendiment
- SLOs, Alertes i Gestió d'Incidents
Mòdul 7: Seguretat en Microserveis
- Autenticació i Autorització
- Seguretat en la Comunicació
- Pràctiques de Seguretat
- Seguretat en Contenidors i Kubernetes
