A 06-01 vam deixar TechCorp amb logs consultables per requestId i un p95 de POST /v1/comandes de 250 ms. El que aquests senyals no diuen és on se'n van aquests 250 ms: a la crida a Catàleg, a la de Clients, a PostgreSQL, al mateix Express? I quan la petició acaba amb un 202, la saga continua per RabbitMQ en tres serveis més: els logs de cadascun existeixen, però ningú no els uneix en un sol recorregut. Les traces distribuïdes són el senyal que dibuixa aquest camí. Aquesta lliçó instrumenta els serveis de TechCorp amb OpenTelemetry, propaga el context per HTTP i per RabbitMQ, munta el Collector i Jaeger a Kubernetes i ensenya a llegir una traça real de com-88213.

Contingut

  1. El problema: una petició, quatre serveis i una saga
  2. Traces, spans i context: traceparent i la seva relació amb X-Request-Id
  3. OpenTelemetry: API, SDK, instrumentacions, Collector i exportadors
  4. Instrumentar Node.js: src/telemetria.js a la plantilla i al Dockerfile
  5. Spans manuals al cas d'ús crearComanda
  6. Propagar el context per RabbitMQ: outbox, consumidors i links
  7. El Collector i Jaeger a Kubernetes
  8. Llegir una traça: com-88213 i el coll d'ampolla
  9. Correlació traces ↔ logs i traces ↔ mètriques
  10. Mostreig i cost

  1. El problema: una petició, quatre serveis i una saga

Seguim la comanda de l'Ana Ruiz des del navegador:

sequenceDiagram
  participant G as gateway :8080
  participant P as servei-comandes :3002
  participant C as servei-cataleg :3001
  participant K as servei-clients :3004
  participant R as RabbitMQ
  participant I as servei-inventari :3006
  G->>P: POST /v1/comandes (X-Request-Id)
  P->>C: GET /v1/productes?ids=p-501,p-777
  P->>K: GET /v1/clients/c-1024
  P->>P: INSERT comanda + outbox (pg)
  P-->>G: 202 Accepted
  Note over P,R: relay de l'outbox, segons després
  P->>R: comanda.creada
  R->>I: inventari.comandes
  I->>R: estoc.reservat

Amb el que tenim, cada tram deixa un log d'accés amb el seu responseTime i una observació a l'histograma de 06-01. Però per respondre "per què aquesta petició va trigar 1,8 s?" caldria obrir Loki, buscar el requestId, anotar a mà els temps de quatre serveis i restar-los. I la part asíncrona ni tan sols comparteix requestId de manera natural: el consumidor d'Inventari rep un missatge, no una petició.

Una traça fa aquesta feina automàticament: registra cada operació com un span amb inici, durada, atributs i pare, i les uneix amb un identificador comú. Veure-les a Jaeger és veure el sequenceDiagram anterior amb temps reals.

  1. Traces, spans i context: traceparent i la seva relació amb X-Request-Id

Concepte Definició A TechCorp
Traça (trace) L'arbre complet d'operacions que provoca una acció; s'identifica per un trace_id de 128 bits Tot el que passa arran d'un POST /v1/comandes, saga inclosa
Span Una operació amb nom, inici, fi, atributs, esdeveniments i estat; té span_id i parent_span_id POST /v1/comandes a Comandes, GET /v1/productes a Catàleg, pg.query INSERT, crearComanda
Context de traça El parell (trace_id, span_id del pare) més flags, que viatja entre processos Capçalera HTTP traceparent; capçalera AMQP traceparent
Propagació Injectar el context en sortir i extreure'l en entrar Automàtica a fetch/Express; manual a RabbitMQ (apartat 6)

El format estàndard és W3C Trace Context: una capçalera traceparent amb quatre camps separats per guions:

traceparent: 00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01
             │  │                                │                │
             │  trace_id (32 hex)                span_id pare     flags (01 = mostrejada)
             versió

I una d'opcional, tracestate, per a dades de proveïdors. Tot el que parli HTTP a TechCorp (gateway, serveis, fetch) ha de reenviar traceparent igual que reenviava X-Request-Id.

Substitueix traceparent l'X-Request-Id? No: conviuen amb papers diferents.

X-Request-Id (03-01) traceparent (W3C)
Qui el genera middlewareRequestId() o el gateway L'SDK d'OpenTelemetry
Què identifica La petició de negoci, llegible (req-01J5Q…) La traça tècnica i l'span pare
On es veu Logs, respostes d'error RFC 7807, suport al client Jaeger, i com a trace_id als logs (apartat 9)
Mostreig Sempre present Pot no mostrejar-se (apartat 10)

Decisió: es mantenen tots dos. El requestId és el que un operador de suport demana al client i busca a Loki; el trace_id és el que el desenvolupador obre a Jaeger. Tots dos apareixen a cada línia de log, de manera que passar de l'un a l'altre és una consulta.

  1. OpenTelemetry: API, SDK, instrumentacions, Collector i exportadors

OpenTelemetry (OTel) és l'estàndard de la CNCF que unifica com es generen i es transporten traces, mètriques i logs, independentment del proveïdor que les emmagatzemi. Les seves peces:

flowchart LR
  subgraph proces [Procés Node.js: servei-comandes]
    API[API OTel<br/>tracer.startActiveSpan] --> SDK[SDK<br/>processadors + mostreig]
    AUTO[Instrumentacions automàtiques<br/>http, express, pg, mongodb, amqplib] --> SDK
    SDK --> EXP[Exportador OTLP]
  end
  EXP -->|OTLP gRPC :4317| COL[OpenTelemetry Collector<br/>receivers → processors → exporters]
  COL --> J[(Jaeger / Tempo)]
  COL --> PR[(Prometheus)]
  • API: el que fa servir el codi de l'aplicació (trace.getTracer, startActiveSpan, propagation.inject). És estable i no depèn de la implementació: si no hi ha SDK carregat, no fa res.
  • SDK: la implementació que crea els spans de debò, aplica el mostreig, els agrupa en lots i els lliura a l'exportador.
  • Instrumentacions automàtiques: monkey-patching de llibreries conegudes per crear spans sense tocar el nostre codi: http (entrant i sortint, inclòs el fetch global de Node 20 des de @opentelemetry/instrumentation-undici), express (un span per middleware/ruta), pg, mongodb, amqplib.
  • Exportadors: envien per OTLP (el protocol natiu d'OTel, gRPC al 4317 o HTTP al 4318) al Collector, o directament a un backend.
  • Collector: un procés intermedi que rep, processa (lots, mostreig de cua, enriquiment amb metadades de Kubernetes) i reexporta a un o diversos destins. Desacobla l'aplicació del backend: canviar Jaeger per Tempo és canviar la configuració del Collector, no redesplegar set serveis.

  1. Instrumentar Node.js: src/telemetria.js a la plantilla i al Dockerfile

Les instrumentacions automàtiques apedacen els mòduls quan es carreguen, així que l'SDK s'ha d'inicialitzar abans que Express, pg o amqplib. La manera neta és un fitxer a part carregat amb --require, sense tocar servidor.js:

npm install @opentelemetry/sdk-node @opentelemetry/auto-instrumentations-node \
            @opentelemetry/exporter-trace-otlp-grpc @opentelemetry/resources @opentelemetry/semantic-conventions
// src/telemetria.js — s'executa abans que l'aplicació
const { NodeSDK } = require('@opentelemetry/sdk-node');
const { getNodeAutoInstrumentations } = require('@opentelemetry/auto-instrumentations-node');
const { OTLPTraceExporter } = require('@opentelemetry/exporter-trace-otlp-grpc');
const { Resource } = require('@opentelemetry/resources');
const { ATTR_SERVICE_NAME, ATTR_SERVICE_VERSION } = require('@opentelemetry/semantic-conventions');

const sdk = new NodeSDK({
  resource: new Resource({
    [ATTR_SERVICE_NAME]: process.env.OTEL_SERVICE_NAME,          // "servei-comandes"
    [ATTR_SERVICE_VERSION]: process.env.SERVEI_VERSIO,           // "1.4.2", la mateixa que el logger de 06-01
    'deployment.environment': process.env.ENTORN || 'local'
  }),
  traceExporter: new OTLPTraceExporter(),                        // llegeix OTEL_EXPORTER_OTLP_ENDPOINT
  instrumentations: [
    getNodeAutoInstrumentations({
      '@opentelemetry/instrumentation-fs': { enabled: false },   // soroll: cada lectura de fitxer seria un span
      '@opentelemetry/instrumentation-http': {
        ignoreIncomingRequestHook: (req) => req.url.startsWith('/health') || req.url === '/metrics'
      },
      '@opentelemetry/instrumentation-express': { enabled: true },
      '@opentelemetry/instrumentation-pg': { enhancedDatabaseReporting: false },   // no incloure els valors dels paràmetres SQL
      '@opentelemetry/instrumentation-mongodb': { enabled: true },
      '@opentelemetry/instrumentation-amqplib': { enabled: true },
      '@opentelemetry/instrumentation-pino': { enabled: true }   // trace_id/span_id als logs (apartat 9)
    })
  ]
});

sdk.start();

process.on('SIGTERM', () => {
  sdk.shutdown().catch(() => {}).finally(() => process.exit(0));   // buidar el lot d'spans abans de morir
});

Explicació:

  • resource descriu qui emet: service.name és el que Jaeger mostra com a servei; service.version permet comparar el canary (05-04) amb la versió estable. OTLPTraceExporter() sense arguments agafa el destí d'OTEL_EXPORTER_OTLP_ENDPOINT, que va al ConfigMap del servei (05-02).
  • getNodeAutoInstrumentations activa tot el catàleg; desactivem fs i excloem les sondes i /metrics dels spans entrants per la mateixa raó que als logs de 06-01. enhancedDatabaseReporting: false: l'span de pg inclou la sentència SQL però no els paràmetres: les dades de l'Ana Ruiz no han de viatjar a Jaeger (mateixa regla que la redacció de logs).
  • El handler de SIGTERM és important: els spans s'exporten en lots cada pocs segons; sense shutdown() l'últim lot d'un pod que mor en un rolling es perd. Conviu amb l'aturada ordenada del servidor de 04-02.

Variables d'entorn estàndard d'OTel que afegim a la plantilla de configuració (04-03) i al ConfigMap:

Variable Valor a techcorp Per a què
OTEL_SERVICE_NAME servei-comandes Nom del servei a les traces
OTEL_EXPORTER_OTLP_ENDPOINT http://otel-collector.observabilitat.svc.cluster.local:4317 Destí OTLP
OTEL_TRACES_SAMPLER / OTEL_TRACES_SAMPLER_ARG parentbased_traceidratio / 0.1 en prod, 1.0 en dev Mostreig (apartat 10)
OTEL_PROPAGATORS tracecontext,baggage (per defecte) W3C Trace Context
SERVEI_VERSIO Injectada per Kustomize amb l'etiqueta de la imatge service.version i camp versio del logger

L'arrencada canvia al package.json i al Dockerfile de 05-01:

"scripts": {
  "start": "node --require ./src/telemetria.js src/servidor.js",
  "dev": "nodemon -r dotenv/config --require ./src/telemetria.js src/servidor.js"
}
# última etapa del Dockerfile multi-stage (05-01)
USER node
CMD ["node", "--require", "./src/telemetria.js", "src/servidor.js"]

--require carrega el mòdul abans de la primera línia de servidor.js; a partir d'aquí, cada petició Express, cada fetch sortint i cada consulta pg produeix spans sense més codi. Amb això, un POST /v1/comandes ja genera a Jaeger la traça gateway → comandes → catàleg/clients → pg. El que falta és el que la instrumentació automàtica no sap: on comença i acaba la nostra lògica de negoci.

  1. Spans manuals al cas d'ús crearComanda

Un span propi marca l'operació de negoci i hi afegeix atributs que després es poden cercar a Jaeger. Modifiquem el cas d'ús de 04-04:

// servei-comandes/src/casosUs/crearComanda.js
const { trace, SpanStatusCode } = require('@opentelemetry/api');
const tracer = trace.getTracer('servei-comandes');

function crearCasUsCrearComanda({ repositori, catalegClient, clientsClient, logger, metriques }) {
  return async function crearComanda(dades, { requestId }) {
    return tracer.startActiveSpan('crearComanda', async (span) => {
      span.setAttribute('client.id', dades.clientId);
      span.setAttribute('comanda.linies', dades.linies.length);
      span.setAttribute('techcorp.request_id', requestId);
      try {
        const [productes, client] = await Promise.all([
          catalegClient.obtenirProductes(dades.linies.map((l) => l.producteId), { requestId }),
          clientsClient.obtenirClient(dades.clientId, { requestId })
        ]);
        const comanda = await repositori.desarAmbOutbox(construirComanda(dades, productes, client));
        span.setAttribute('comanda.id', comanda.id);
        span.setAttribute('comanda.total', comanda.total);
        span.addEvent('comanda.persistida');
        metriques.creades.inc();
        return comanda;
      } catch (err) {
        span.recordException(err);
        span.setStatus({ code: SpanStatusCode.ERROR, message: err.codi || err.message });
        throw err;
      } finally {
        span.end();
      }
    });
  };
}
  • trace.getTracer('servei-comandes'): obté un tracer de l'API. Només importem @opentelemetry/api: el cas d'ús no coneix l'SDK, i a les proves de 04-05 sense SDK carregat els spans són "no-op".
  • startActiveSpan('crearComanda', fn): crea l'span com a fill de l'actiu (l'span POST /v1/comandes d'Express) i el deixa actiu durant fn, de manera que els fetch a Catàleg i Clients i l'INSERT de pg en pengen automàticament. Aquest és el truc del "context actiu": la instrumentació automàtica busca l'span actiu a l'AsyncLocalStorage de Node i s'hi enganxa.
  • Els atributs segueixen la convenció domini.camp (comanda.id, client.id); són valors d'alta cardinalitat, que en traces que són benvinguts (a diferència de les etiquetes de mètriques de 06-01): Jaeger permet cercar comanda.id=com-88213.
  • addEvent afegeix una marca temporal dins de l'span; recordException + setStatus(ERROR) fan que la traça aparegui en vermell i amb l'stack (sense això, un ErrorNegoci que acaba en 503 es veuria com un span verd que acaba abans d'hora); i span.end() va sempre al finally: un span sense tancar no s'exporta mai.

Els mateixos tres gestos (startActiveSpan, atributs, recordException) s'apliquen a crearCasUsReservarEstoc d'Inventari i a cobrar de Pagaments.

  1. Propagar el context per RabbitMQ: outbox, consumidors i links

La instrumentació d'amqplib injecta traceparent a les capçaleres quan es publica dins d'un span actiu i l'extreu en consumir. A TechCorp hi ha una trampa: l'esdeveniment comanda.creada no el publica el cas d'ús, sinó el relay de l'outbox (04-04), en un altre moment i sense cap span actiu relacionat amb la petició. Si no fem res, la saga arrenca una traça nova sense relació amb el POST.

Solució: desar el context a la fila de l'outbox en escriure-la i restaurar-lo en publicar.

// servei-comandes/src/repositori/comandaRepositori.js — en inserir a outbox
const { propagation, context } = require('@opentelemetry/api');

function capcaleresDeContext() {
  const portador = {};
  propagation.inject(context.active(), portador);      // escriu traceparent (i tracestate) a l'objecte
  return portador;                                      // { traceparent: '00-4bf9…-00f0…-01' }
}

// INSERT INTO outbox (id, tipus, dades, capcaleres) VALUES ($1, $2, $3, $4)
// capcaleres = { requestId, ...capcaleresDeContext() }

propagation.inject agafa el context actiu (som dins de crearComanda, apartat 5) i escriu les capçaleres W3C en un objecte qualsevol; aquest objecte es desa a la columna capcaleres (JSONB) al costat del requestId que ja desàvem a 03-02.

Al relay (missatgeria/relayOutbox.js), en publicar cada fila:

const { propagation, context, trace, SpanKind } = require('@opentelemetry/api');
const tracer = trace.getTracer('servei-comandes');

async function publicarFila(fila) {
  const contextOrigen = propagation.extract(context.active(), fila.capcaleres);   // reconstrueix el context desat
  const spanOrigen = trace.getSpan(contextOrigen)?.spanContext();

  await tracer.startActiveSpan(`publicar ${fila.tipus}`, {
    kind: SpanKind.PRODUCER,
    links: spanOrigen ? [{ context: spanOrigen }] : [],                              // enllaç, no pare
    attributes: { 'messaging.system': 'rabbitmq', 'messaging.destination.name': 'techcorp.esdeveniments', 'esdeveniment.id': fila.id, 'esdeveniment.tipus': fila.tipus }
  }, async (span) => {
    const capcaleres = { ...fila.capcaleres };
    propagation.inject(context.active(), capcaleres);                                // traceparent de l'span PRODUCER
    canal.publish('techcorp.esdeveniments', fila.tipus, Buffer.from(JSON.stringify(fila.dades)), { headers: capcaleres, messageId: fila.id, persistent: true });
    span.end();
  });
}

Aquí hi ha una decisió de disseny. Podríem fer que l'span de publicació fos fill del POST /v1/comandes original, però aquest span va acabar fa segons: la traça mostraria un fill que comença després que el seu pare acabi, i les traces de sagues de 15 minuts serien inmanejables. OTel té el concepte de link per a això: l'span de publicació pertany a la seva pròpia traça (la del relay/la saga) però enllaça amb l'span original. Jaeger mostra els enllaços i permet saltar d'una traça a una altra. La regla a TechCorp:

  • Dins d'una petició síncrona (HTTP): relació pare-fill.
  • Entre la petició i el processament asíncron (outbox, consumidors): link a l'span d'origen i una traça per esdeveniment.
  • Dins d'un consumidor, tot el que provoqui (consultes, publicació de l'esdeveniment següent) és fill de l'span de consum, de manera que cada pas de la saga és una traça petita enllaçada a l'anterior: POSTpublicar comanda.creadaconsumir comanda.creada (Inventari) ⇢ publicar estoc.reservat ⇢ …

Al consumidor de la saga (missatgeria/consumidorSaga.js) la instrumentació d'amqplib ja crea un span comandes.saga process amb SpanKind.CONSUMER a partir del traceparent de les capçaleres. Només hi afegim atributs i, si el missatge ve d'un reintent o de la DLQ (06-03), conservem les capçaleres per no trencar la cadena. Quan un servei publica sense passar per outbox (Inventari publica estoc.reservat dins de l'span de consum), la injecció automàtica n'hi ha prou.

  1. El Collector i Jaeger a Kubernetes

L'equip de Plataforma desplega el Collector al namespace observabilitat com a Deployment (dues rèpliques darrere d'un Service otel-collector amb els ports 4317/4318) i la seva configuració en un ConfigMap:

apiVersion: v1
kind: ConfigMap
metadata:
  name: otel-collector-config
  namespace: observabilitat
data:
  config.yaml: |
    receivers:
      otlp:
        protocols:
          grpc: { endpoint: 0.0.0.0:4317 }
          http: { endpoint: 0.0.0.0:4318 }
    processors:
      batch:
        timeout: 5s
        send_batch_size: 512
      memory_limiter:
        check_interval: 1s
        limit_mib: 400
      k8sattributes: {}                     # afegeix k8s.namespace, k8s.pod.name, k8s.deployment.name a cada span
      tail_sampling:                        # vegeu l'apartat 10
        decision_wait: 10s
        policies:
          - { name: errors, type: status_code, status_code: { status_codes: [ERROR] } }
          - { name: lentes, type: latency, latency: { threshold_ms: 1000 } }
          - { name: resta, type: probabilistic, probabilistic: { sampling_percentage: 10 } }
    exporters:
      otlp/jaeger:
        endpoint: jaeger-collector.observabilitat.svc.cluster.local:4317
        tls: { insecure: true }
      prometheus:
        endpoint: 0.0.0.0:8889              # mètriques derivades d'spans per a Prometheus (06-01)
    connectors:
      spanmetrics: {}                       # genera mètriques RED (crides, durada) a partir dels spans
    service:
      pipelines:
        traces:   { receivers: [otlp], processors: [memory_limiter, k8sattributes, tail_sampling, batch], exporters: [otlp/jaeger, spanmetrics] }
        metrics:  { receivers: [spanmetrics], exporters: [prometheus] }
  • receivers.otlp: accepta el que envien els serveis (gRPC 4317, HTTP 4318).
  • processors: memory_limiter protegeix el Collector; k8sattributes etiqueta cada span amb el pod i el Deployment d'origen (mateixos noms que a Loki: es creuen bé); tail_sampling decideix quines traces conservar després de veure-les completes; batch agrupa abans d'exportar.
  • exporters.otlp/jaeger: Jaeger v2 accepta OTLP directament. Canviar-lo per Grafana Tempo és canviar l'endpoint; Tempo s'integra millor amb Grafana/Loki, Jaeger té la interfície més coneguda. TechCorp comença amb Jaeger (jaeger-query publicat internament a jaeger.techcorp.internal).
  • El connector spanmetrics genera mètriques RED (traces_span_metrics_calls_total, traces_span_metrics_duration_milliseconds) a partir dels spans; les exposa al 8889 perquè Prometheus (06-01) les rasqui. És una segona font de RED per servei i operació, útil sobretot per a les crides sortints.

Al ConfigMap de cada servei (05-02) afegim OTEL_EXPORTER_OTLP_ENDPOINT i OTEL_SERVICE_NAME; al compose.yaml de local (05-01) un contenidor jaegertracing/all-in-one amb OTLP activat i la interfície a http://localhost:16686 serveix per desenvolupar sense Collector.

  1. Llegir una traça: com-88213 i el coll d'ampolla

La Marta cerca a Jaeger servei=servei-comandes, comanda.id=com-88213 i obre la traça. Jaeger la mostra com a diagrama de Gantt; en forma de taula (temps ficticis però realistes):

Servei Span Inici (ms) Durada (ms) Pare
gateway POST /v1/comandes 0 186
gateway proxy → servei-comandes 3 183 gateway
servei-comandes POST /v1/comandes (http) 6 180 proxy
servei-comandes middleware - jsonParser 6 1 POST
servei-comandes crearComanda 8 176 POST
servei-comandes GET servei-cataleg:3001/v1/productes (fetch) 9 40 crearComanda
servei-cataleg GET /v1/productes 11 36 fetch catàleg
servei-cataleg mongodb.find productes 14 9 GET productes
servei-comandes GET servei-clients:3004/v1/clients/c-1024 (fetch) 9 25 crearComanda
servei-clients GET /v1/clients/:id 11 21 fetch clients
servei-clients pg.query SELECT clients 13 6 GET clients
servei-comandes pg.query BEGIN / INSERT comandes / INSERT outbox / COMMIT 52 12 crearComanda
servei-comandes (sense span) 64 120 crearComanda

Com es llegeix:

  1. El gateway afegeix 3 ms de sobrecàrrega: menyspreable.
  2. Catàleg (40 ms) i Clients (25 ms) s'executen en paral·lel (Promise.all de l'apartat 5): comencen tots dos al ms 9. Si fossin seqüencials, la traça mostraria el segon començant quan acaba el primer: la primera optimització que una traça sol revelar.
  3. Les consultes a base de dades són ràpides: MongoDB 9 ms, PostgreSQL 6 i 12 ms.
  4. Sumant: 40 (el més lent del paral·lel) + 12 de pg = 52 ms de feina real, però crearComanda dura 176 ms. Hi ha 120 ms sense span entre el COMMIT (ms 64) i el final de l'span (ms 184). És un forat: temps que va passar dins del nostre codi sense cap operació instrumentada. En mirar el cas d'ús, el Luis troba que després de persistir es crida una funció calcularRecomanacions heretada del monòlit que fa un càlcul síncron pesat. Bloqueja l'event loop (06-04) i no aporta res a la comanda. S'elimina i el p95 de POST /v1/comandes cau de 250 a 90 ms.

Regles pràctiques de lectura: buscar l'span més llarg entre germans; buscar forats sense fills (codi propi no instrumentat o event loop bloquejat); comprovar si els fills són seqüencials quan podrien ser paral·lels; i, a la part asíncrona, seguir els links d'una traça a la següent per mesurar la saga completa (publicar comanda.creada a les 10:21:04.6, consumir comanda.confirmada a les 10:21:07.9: 3,3 s, coherent amb l'histograma saga_durada_segons de 06-01).

  1. Correlació traces ↔ logs i traces ↔ mètriques

Traces ↔ logs. La instrumentació @opentelemetry/instrumentation-pino (activada a l'apartat 4) afegeix automàticament trace_id, span_id i trace_flags a cada línia de log emesa dins d'un span actiu. La línia de 06-01 passa a ser:

{"nivell":"info","time":"2026-08-15T10:21:04.512Z","servei":"servei-comandes","versio":"1.4.2","requestId":"req-01J5Q7X2N9C4M8Z0K1T3V6W8Y","trace_id":"4bf92f3577b34da6a3ce929d0e0e4736","span_id":"00f067aa0ba902b7","comandaId":"com-88213","missatge":"comanda creada"}

Si no es vol dependre d'aquesta instrumentació, un mixin a crearLogger fa el mateix a mà:

const { trace, context } = require('@opentelemetry/api');
// a les opcions de pino():
mixin() {
  const span = trace.getSpan(context.active());
  if (!span) return {};
  const { traceId, spanId } = span.spanContext();
  return { trace_id: traceId, span_id: spanId };
}

Amb això, a Grafana es configura al datasource de Loki un derived field: expressió "trace_id":"(\w+)" → enllaç a Jaeger/Tempo per trace_id. I en sentit invers, des d'un span a Jaeger, un enllaç a Loki amb {namespace="techcorp"} | json | trace_id="…". L'operador ja no tria entre logs i traces: salta.

Traces ↔ mètriques. Els exemplars permeten que un bucket de l'histograma http_request_duration_seconds porti adjunt el trace_id d'una petició representativa; Grafana els pinta com a punts sobre la gràfica del p95 i un clic obre la traça. Requereix prom-client amb suport d'exemplars i --enable-feature=exemplar-storage a Prometheus. Ho deixem com a menció: TechCorp ho activarà quan la pila sigui estable; el salt mètrica → logs → traça del punt anterior cobreix el mateix cas amb un pas més.

  1. Mostreig i cost

Cada POST /v1/comandes genera uns 12 spans; amb la saga, uns 30. A 3.000 comandes/dia és poc, però el catàleg serveix centenars de milers de GET /v1/productes i en Black Friday ×20. Desar el 100 % és car (emmagatzematge i CPU al Collector) i gairebé sempre innecessari: les traces "normals" s'assemblen totes. Per això es mostreja:

Estratègia On decideix Avantatge Inconvenient
Head sampling Al servei, en crear l'span arrel Barat; els serveis descarten abans d'exportar Decideix sense saber si la traça acabarà en error o serà lenta
Tail sampling Al Collector, amb la traça completa Conserva el 100 % d'errors i lentes El Collector ha de retenir traces en memòria (decision_wait) i veure tots els spans d'una traça a la mateixa rèplica

TechCorp combina totes dues:

  • Als serveis, OTEL_TRACES_SAMPLER=parentbased_traceidratio amb OTEL_TRACES_SAMPLER_ARG=0.1 en producció (10 % de les traces arrel; parentbased vol dir que si el pare va ser mostrejat, el fill també: una traça mai no surt a mitges) i 1.0 en dev/staging.
  • Al Collector, la política tail_sampling de l'apartat 7: totes les traces amb error o de més d'1 s, i el 10 % de la resta. Com que el head ja va reduir al 10 %, el tail actua sobre aquest subconjunt; perquè els errors no es perdin al head es pot apujar la ràtio dels serveis crítics (Comandes, Pagaments: 0,5) i deixar el 0,1 per a Catàleg.
  • Retenció a Jaeger: 7 dies. Els requestId dels logs continuen 30 dies, així que un incident antic s'investiga amb logs encara que la seva traça hagi expirat.

Última nota, per tancar el fil amb 05-05: un service mesh com Istio genera spans a cada sidecar (entrada i sortida de cada pod) i els envia al mateix Collector, però no pot propagar el context dins de l'aplicació: entre la petició entrant i la sortint hi ha codi nostre, i només el nostre procés sap que la segona és conseqüència de la primera. Si algun dia TechCorp adopta el mesh, l'aplicació continuarà necessitant exactament aquesta lliçó: reenviar traceparent, i amb RabbitMQ, injectar-lo i extreure'l a mà.

Errors Comuns i Consells

  • Carregar l'SDK després d'Express o pg. No s'instrumenta res i no hi ha error. Sempre --require ./src/telemetria.js (o NODE_OPTIONS=--require), mai un require dins de servidor.js després d'altres imports.
  • Oblidar span.end() en un camí d'error. L'span no s'exporta i la traça queda "oberta" a Jaeger. Fer servir sempre try/finally o la variant amb callback que tanca sola.
  • Perdre el context a setTimeout, cues internes o EventEmitter. L'AsyncLocalStorage segueix await i promises, però un setTimeout o un emitter.on es poden executar fora del context. context.with(ctx, fn) el restaura.
  • Fer pare-fill el que ha de ser link. Traces de 15 minuts amb milers d'spans i pares que acaben abans que els seus fills. Asíncron = links.
  • Posar dades personals als atributs (client.email, cossos de peticions). Apliquen les mateixes regles que als logs (06-01, 07-03).
  • Mostreig 100 % en producció "per no perdre res". Es perd el Collector. Head 10 % + tail per a errors.
  • Consell: en local, jaegertracing/all-in-one a compose.yaml i OTEL_TRACES_SAMPLER_ARG=1.0; veure la traça d'un curl a POST /v1/comandes és la millor manera d'entendre l'estructura d'una petició.

Exercicis

Exercici 1: reintent des de la DLQ sense trencar la traça

Un missatge de comandes.saga va anar a comandes.saga.dlq i es reprocessa manualment (06-03). Què cal conservar perquè el nou processament aparegui enllaçat a la traça original? Hauria de ser fill o link? Escriu el fragment que crea l'span de reprocés.

Exercici 2: llegir la traça

En una traça de POST /v1/comandes veus: crearComanda 410 ms; fetch catàleg comença al ms 5 i dura 45 ms; fetch clients comença al ms 52 i dura 30 ms; pg 15 ms a partir del ms 84; res més fins al ms 410. Enumera els dos problemes i què faries amb cadascun.

Solucions

Exercici 1

Cal conservar les capçaleres del missatge original (traceparent, requestId, esdevenimentId) quan l'script mou el missatge de la DLQ a la cua principal. El reprocés passa molt després i per iniciativa humana: ha de ser link, no fill. Fragment de l'script reprocessarDlq.js:

const ctxOriginal = propagation.extract(context.active(), missatge.properties.headers);
const spanOriginal = trace.getSpan(ctxOriginal)?.spanContext();
await tracer.startActiveSpan('reprocessar dlq', { kind: SpanKind.PRODUCER, links: spanOriginal ? [{ context: spanOriginal }] : [],
  attributes: { 'esdeveniment.id': missatge.properties.messageId, 'dlq.origen': 'comandes.saga.dlq' } }, async (span) => {
  const headers = { ...missatge.properties.headers, 'x-reproces': 'manual' };
  propagation.inject(context.active(), headers);       // sobreescriu traceparent amb el de l'span de reprocés
  canal.publish('techcorp.esdeveniments', missatge.fields.routingKey, missatge.content, { headers, messageId: missatge.properties.messageId, persistent: true });
  span.end();
});

El consumidor rebrà el traceparent de l'span de reprocés i, a través del link, es podrà arribar a la traça de la fallada original.

Exercici 2

  1. Crides seqüencials que podrien ser paral·leles. Clients comença al ms 52, just quan acaba Catàleg. Amb Promise.all la fase de validació duraria 45 ms en comptes de 75. Canvi al cas d'ús.
  2. Forat de ~310 ms sense spans després del COMMIT (ms 99 → ms 410). És codi propi no instrumentat o event loop bloquejat. Es localitza embolcallant les funcions sospitoses del cas d'ús en spans manuals (o amb nodejs_eventloop_lag_seconds de 06-01 en aquest pod) i s'elimina, es fa asíncron, o es mou fora de la petició (a un esdeveniment). El resultat esperat: crearComanda al voltant de 60-70 ms.

Conclusió

Amb aquesta lliçó, TechCorp veu per fi el camí de cada petició. Cada servei arrenca amb node --require ./src/telemetria.js, que carrega l'SDK d'OpenTelemetry amb instrumentacions automàtiques d'http, express, pg, mongodb, amqplib i pino, s'identifica amb OTEL_SERVICE_NAME i service.version, i exporta per OTLP al Collector; els casos d'ús afegeixen spans manuals (crearComanda, reservarEstoc, cobrar) amb atributs comanda.id/client.id i excepcions registrades; el context W3C traceparent viatja per HTTP al costat de l'X-Request-Id, es desa a la fila de l'outbox i es reinjecta al relay, i les etapes asíncrones de la saga s'uneixen amb links; el Collector aplica k8sattributes, tail_sampling i batch i reexporta a Jaeger i a Prometheus; i trace_id a cada línia de log tanca el triangle amb Loki. En llegir la traça de com-88213 hem trobat un forat de 120 ms que cap mètrica no delatava. Ja podem veure les fallades i les lentituds; la lliçó següent s'ocupa de sobreviure-hi: els patrons de gestió d'errors i recuperació (timeouts, reintents, circuit breaker, bulkhead, cues de reintent i DLQ, el vigilant de la saga) que els mòduls 2, 3 i 5 van anar remetent a 06-03.

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