En 06-01 dejamos a TechCorp con logs consultables por requestId y un p95 de POST /v1/pedidos de 250 ms. Lo que esas señales no dicen es dónde se van esos 250 ms: ¿en la llamada a Catálogo, en la de Clientes, en PostgreSQL, en el propio Express? Y cuando la petición termina con un 202, la saga sigue por RabbitMQ en otros tres servicios: los logs de cada uno existen, pero nadie los une en un solo recorrido. Las trazas distribuidas son la señal que dibuja ese camino. Esta lección instrumenta los servicios de TechCorp con OpenTelemetry, propaga el contexto por HTTP y por RabbitMQ, monta el Collector y Jaeger en Kubernetes y enseña a leer una traza real de ped-88213.
Contenido
- El problema: una petición, cuatro servicios y una saga
- Trazas, spans y contexto:
traceparenty su relación conX-Request-Id - OpenTelemetry: API, SDK, instrumentaciones, Collector y exportadores
- Instrumentar Node.js:
src/telemetria.jsen la plantilla y en el Dockerfile - Spans manuales en el caso de uso
crearPedido - Propagar el contexto por RabbitMQ: outbox, consumidores y
links - El Collector y Jaeger en Kubernetes
- Leer una traza:
ped-88213y el cuello de botella - Correlación trazas ↔ logs y trazas ↔ métricas
- Muestreo y coste
- El problema: una petición, cuatro servicios y una saga
Sigamos el pedido de Ana Ruiz desde el navegador:
sequenceDiagram participant G as gateway :8080 participant P as servicio-pedidos :3002 participant C as servicio-catalogo :3001 participant K as servicio-clientes :3004 participant R as RabbitMQ participant I as servicio-inventario :3006 G->>P: POST /v1/pedidos (X-Request-Id) P->>C: GET /v1/productos?ids=p-501,p-777 P->>K: GET /v1/clientes/c-1024 P->>P: INSERT pedido + outbox (pg) P-->>G: 202 Accepted Note over P,R: relay del outbox, segundos después P->>R: pedido.creado R->>I: inventario.pedidos I->>R: stock.reservado
Con lo que tenemos, cada tramo deja un log de acceso con su responseTime y una observación en el histograma de 06-01. Pero para responder "¿por qué esta petición tardó 1,8 s?" habría que abrir Loki, buscar el requestId, anotar a mano los tiempos de cuatro servicios y restarlos. Y la parte asíncrona ni siquiera comparte requestId de forma natural: el consumidor de Inventario recibe un mensaje, no una petición.
Una traza hace ese trabajo automáticamente: registra cada operación como un span con inicio, duración, atributos y padre, y las une con un identificador común. Verlas en Jaeger es ver la sequenceDiagram anterior con tiempos reales.
- Trazas, spans y contexto:
traceparent y su relación con X-Request-Id
traceparent y su relación con X-Request-Id| Concepto | Definición | En TechCorp |
|---|---|---|
| Traza (trace) | El árbol completo de operaciones que provoca una acción; se identifica por un trace_id de 128 bits |
Todo lo que ocurre a raíz de un POST /v1/pedidos, incluida la saga |
| Span | Una operación con nombre, inicio, fin, atributos, eventos y estado; tiene span_id y parent_span_id |
POST /v1/pedidos en Pedidos, GET /v1/productos en Catálogo, pg.query INSERT, crearPedido |
| Contexto de traza | El par (trace_id, span_id del padre) más flags, que viaja entre procesos |
Cabecera HTTP traceparent; cabecera AMQP traceparent |
| Propagación | Inyectar el contexto al salir y extraerlo al entrar | Automática en fetch/Express; manual en RabbitMQ (apartado 6) |
El formato estándar es W3C Trace Context: una cabecera traceparent con cuatro campos separados por guiones:
traceparent: 00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01
│ │ │ │
│ trace_id (32 hex) span_id padre flags (01 = muestreada)
versiónY una opcional tracestate para datos de proveedores. Todo lo que hable HTTP en TechCorp (gateway, servicios, fetch) debe reenviar traceparent igual que reenviaba X-Request-Id.
¿Sustituye traceparent a X-Request-Id? No: conviven con papeles distintos.
X-Request-Id (03-01) |
traceparent (W3C) |
|
|---|---|---|
| Quién lo genera | middlewareRequestId() o el gateway |
El SDK de OpenTelemetry |
| Qué identifica | La petición de negocio, legible (req-01J5Q…) |
La traza técnica y el span padre |
| Dónde se ve | Logs, respuestas de error RFC 7807, soporte al cliente | Jaeger, y como trace_id en logs (apartado 9) |
| Muestreo | Siempre presente | Puede no muestrearse (apartado 10) |
Decisión: se mantienen los dos. El requestId es lo que un operador de soporte pide al cliente y busca en Loki; el trace_id es lo que el desarrollador abre en Jaeger. Ambos aparecen en cada línea de log, así que pasar de uno a otro es una consulta.
- OpenTelemetry: API, SDK, instrumentaciones, Collector y exportadores
OpenTelemetry (OTel) es el estándar de la CNCF que unifica cómo se generan y transportan trazas, métricas y logs, independiente del proveedor que las almacene. Sus piezas:
flowchart LR
subgraph proceso [Proceso Node.js: servicio-pedidos]
API[API OTel<br/>tracer.startActiveSpan] --> SDK[SDK<br/>procesadores + muestreo]
AUTO[Instrumentaciones automáticas<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: lo que usa el código de la aplicación (
trace.getTracer,startActiveSpan,propagation.inject). Es estable y no depende de la implementación: si no hay SDK cargado, no hace nada. - SDK: la implementación que crea los spans de verdad, aplica el muestreo, los agrupa en lotes y los entrega al exportador.
- Instrumentaciones automáticas: monkey-patching de librerías conocidas para crear spans sin tocar nuestro código:
http(entrante y saliente, incluidofetchglobal de Node 20 desde@opentelemetry/instrumentation-undici),express(un span por middleware/ruta),pg,mongodb,amqplib. - Exportadores: envían por OTLP (el protocolo nativo de OTel, gRPC en 4317 o HTTP en 4318) al Collector, o directamente a un backend.
- Collector: un proceso intermedio que recibe, procesa (lotes, muestreo de cola, enriquecimiento con metadatos de Kubernetes) y reexporta a uno o varios destinos. Desacopla la aplicación del backend: cambiar Jaeger por Tempo es cambiar la configuración del Collector, no redesplegar siete servicios.
- Instrumentar Node.js:
src/telemetria.js en la plantilla y en el Dockerfile
src/telemetria.js en la plantilla y en el DockerfileLas instrumentaciones automáticas parchean los módulos cuando se cargan, así que el SDK debe inicializarse antes que Express, pg o amqplib. La forma limpia es un fichero aparte cargado con --require, sin 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 — se ejecuta antes que la aplicación
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, // "servicio-pedidos"
[ATTR_SERVICE_VERSION]: process.env.SERVICIO_VERSION, // "1.4.2", la misma que el logger de 06-01
'deployment.environment': process.env.ENTORNO || 'local'
}),
traceExporter: new OTLPTraceExporter(), // lee OTEL_EXPORTER_OTLP_ENDPOINT
instrumentations: [
getNodeAutoInstrumentations({
'@opentelemetry/instrumentation-fs': { enabled: false }, // ruido: cada lectura de fichero sería 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 incluir los valores de los parámetros SQL
'@opentelemetry/instrumentation-mongodb': { enabled: true },
'@opentelemetry/instrumentation-amqplib': { enabled: true },
'@opentelemetry/instrumentation-pino': { enabled: true } // trace_id/span_id en los logs (apartado 9)
})
]
});
sdk.start();
process.on('SIGTERM', () => {
sdk.shutdown().catch(() => {}).finally(() => process.exit(0)); // vaciar el lote de spans antes de morir
});Explicación:
resourcedescribe quién emite:service.namees lo que Jaeger muestra como servicio;service.versionpermite comparar el canary (05-04) con la versión estable.OTLPTraceExporter()sin argumentos toma el destino deOTEL_EXPORTER_OTLP_ENDPOINT, que va alConfigMapdel servicio (05-02).getNodeAutoInstrumentationsactiva todo el catálogo; desactivamosfsy excluimos las sondas y/metricsde los spans entrantes por la misma razón que en los logs de 06-01.enhancedDatabaseReporting: false: el span depgincluye la sentencia SQL pero no los parámetros: los datos de Ana Ruiz no deben viajar a Jaeger (misma regla que la redacción de logs).- El handler de
SIGTERMes importante: los spans se exportan en lotes cada pocos segundos; sinshutdown()el último lote de un pod que muere en un rolling se pierde. Convive con el apagado ordenado del servidor de 04-02.
Variables de entorno estándar de OTel que añadimos a la plantilla de configuración (04-03) y al ConfigMap:
| Variable | Valor en techcorp |
Para qué |
|---|---|---|
OTEL_SERVICE_NAME |
servicio-pedidos |
Nombre del servicio en las trazas |
OTEL_EXPORTER_OTLP_ENDPOINT |
http://otel-collector.observabilidad.svc.cluster.local:4317 |
Destino OTLP |
OTEL_TRACES_SAMPLER / OTEL_TRACES_SAMPLER_ARG |
parentbased_traceidratio / 0.1 en prod, 1.0 en dev |
Muestreo (apartado 10) |
OTEL_PROPAGATORS |
tracecontext,baggage (por defecto) |
W3C Trace Context |
SERVICIO_VERSION |
Inyectada por Kustomize con la etiqueta de la imagen | service.version y campo version del logger |
El arranque cambia en el package.json y en el 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 carga el módulo antes de la primera línea de servidor.js; a partir de ahí, cada petición Express, cada fetch saliente y cada consulta pg produce spans sin más código. Con esto, un POST /v1/pedidos ya genera en Jaeger la traza gateway → pedidos → catálogo/clientes → pg. Lo que falta es lo que la instrumentación automática no sabe: dónde empieza y acaba nuestra lógica de negocio.
- Spans manuales en el caso de uso
crearPedido
crearPedidoUn span propio marca la operación de negocio y le añade atributos que luego se pueden buscar en Jaeger. Modificamos el caso de uso de 04-04:
// servicio-pedidos/src/casosUso/crearPedido.js
const { trace, SpanStatusCode } = require('@opentelemetry/api');
const tracer = trace.getTracer('servicio-pedidos');
function crearCasoUsoCrearPedido({ repositorio, catalogoCliente, clientesCliente, logger, metricas }) {
return async function crearPedido(datos, { requestId }) {
return tracer.startActiveSpan('crearPedido', async (span) => {
span.setAttribute('cliente.id', datos.clienteId);
span.setAttribute('pedido.lineas', datos.lineas.length);
span.setAttribute('techcorp.request_id', requestId);
try {
const [productos, cliente] = await Promise.all([
catalogoCliente.obtenerProductos(datos.lineas.map((l) => l.productoId), { requestId }),
clientesCliente.obtenerCliente(datos.clienteId, { requestId })
]);
const pedido = await repositorio.guardarConOutbox(construirPedido(datos, productos, cliente));
span.setAttribute('pedido.id', pedido.id);
span.setAttribute('pedido.total', pedido.total);
span.addEvent('pedido.persistido');
metricas.creados.inc();
return pedido;
} catch (err) {
span.recordException(err);
span.setStatus({ code: SpanStatusCode.ERROR, message: err.codigo || err.message });
throw err;
} finally {
span.end();
}
});
};
}trace.getTracer('servicio-pedidos'): obtiene un tracer de la API. Solo importamos@opentelemetry/api: el caso de uso no conoce el SDK, y en las pruebas de 04-05 sin SDK cargado los spans son "no-op".startActiveSpan('crearPedido', fn): crea el span como hijo del activo (el spanPOST /v1/pedidosde Express) y lo deja activo durantefn, de modo que losfetcha Catálogo y Clientes y elINSERTdepgcuelgan de él automáticamente. Ese es el truco de "contexto activo": la instrumentación automática busca el span activo en elAsyncLocalStoragede Node y se engancha.- Los atributos siguen la convención
dominio.campo(pedido.id,cliente.id); son valores de alta cardinalidad, que en trazas sí son bienvenidos (a diferencia de las etiquetas de métricas de 06-01): Jaeger permite buscarpedido.id=ped-88213. addEventañade una marca temporal dentro del span;recordException+setStatus(ERROR)hacen que la traza aparezca en rojo y con el stack (sin esto, unErrorNegocioque termina en 503 se vería como un span verde que acaba antes de tiempo); yspan.end()va siempre enfinally: un span sin cerrar no se exporta nunca.
Los mismos tres gestos (startActiveSpan, atributos, recordException) se aplican en crearCasoUsoReservarStock de Inventario y en cobrar de Pagos.
- Propagar el contexto por RabbitMQ: outbox, consumidores y
links
linksLa instrumentación de amqplib inyecta traceparent en las cabeceras cuando se publica dentro de un span activo y lo extrae al consumir. En TechCorp hay una trampa: el evento pedido.creado no lo publica el caso de uso, sino el relay del outbox (04-04), en otro momento y sin ningún span activo relacionado con la petición. Si no hacemos nada, la saga arranca una traza nueva sin relación con el POST.
Solución: guardar el contexto en la fila del outbox al escribirla y restaurarlo al publicar.
// servicio-pedidos/src/repositorio/pedidoRepositorio.js — al insertar en outbox
const { propagation, context } = require('@opentelemetry/api');
function cabecerasDeContexto() {
const portador = {};
propagation.inject(context.active(), portador); // escribe traceparent (y tracestate) en el objeto
return portador; // { traceparent: '00-4bf9…-00f0…-01' }
}
// INSERT INTO outbox (id, tipo, datos, cabeceras) VALUES ($1, $2, $3, $4)
// cabeceras = { requestId, ...cabecerasDeContexto() }propagation.inject toma el contexto activo (estamos dentro de crearPedido, apartado 5) y escribe las cabeceras W3C en un objeto cualquiera; ese objeto se guarda en la columna cabeceras (JSONB) junto al requestId que ya guardábamos en 03-02.
En el relay (mensajeria/relayOutbox.js), al publicar cada fila:
const { propagation, context, trace, SpanKind } = require('@opentelemetry/api');
const tracer = trace.getTracer('servicio-pedidos');
async function publicarFila(fila) {
const contextoOrigen = propagation.extract(context.active(), fila.cabeceras); // reconstruye el contexto guardado
const spanOrigen = trace.getSpan(contextoOrigen)?.spanContext();
await tracer.startActiveSpan(`publicar ${fila.tipo}`, {
kind: SpanKind.PRODUCER,
links: spanOrigen ? [{ context: spanOrigen }] : [], // enlace, no padre
attributes: { 'messaging.system': 'rabbitmq', 'messaging.destination.name': 'techcorp.eventos', 'evento.id': fila.id, 'evento.tipo': fila.tipo }
}, async (span) => {
const cabeceras = { ...fila.cabeceras };
propagation.inject(context.active(), cabeceras); // traceparent del span PRODUCER
canal.publish('techcorp.eventos', fila.tipo, Buffer.from(JSON.stringify(fila.datos)), { headers: cabeceras, messageId: fila.id, persistent: true });
span.end();
});
}Aquí hay una decisión de diseño. Podríamos hacer que el span de publicación fuera hijo del POST /v1/pedidos original, pero ese span terminó hace segundos: la traza mostraría un hijo que empieza después de que su padre acabe, y las trazas de sagas de 15 minutos serían inmanejables. OTel tiene el concepto de link para esto: el span de publicación pertenece a su propia traza (la del relay/la saga) pero enlaza con el span original. Jaeger muestra los enlaces y permite saltar de una traza a otra. La regla en TechCorp:
- Dentro de una petición síncrona (HTTP): relación padre-hijo.
- Entre la petición y el procesamiento asíncrono (outbox, consumidores): link al span de origen y una traza por evento.
- Dentro de un consumidor, todo lo que provoque (consultas, publicación del siguiente evento) es hijo del span de consumo, así que cada paso de la saga es una traza pequeña enlazada a la anterior:
POST⇢publicar pedido.creado⇢consumir pedido.creado(Inventario) ⇢publicar stock.reservado⇢ …
En el consumidor de la saga (mensajeria/consumidorSaga.js) la instrumentación de amqplib ya crea un span pedidos.saga process con SpanKind.CONSUMER a partir del traceparent de las cabeceras. Solo añadimos atributos y, si el mensaje viene de un reintento o de la DLQ (06-03), conservamos las cabeceras para no romper la cadena. Cuando un servicio publica sin pasar por outbox (Inventario publica stock.reservado dentro del span de consumo), la inyección automática basta.
- El Collector y Jaeger en Kubernetes
El equipo de Plataforma despliega el Collector en el namespace observabilidad como Deployment (dos réplicas detrás de un Service otel-collector con los puertos 4317/4318) y su configuración en un ConfigMap:
apiVersion: v1
kind: ConfigMap
metadata:
name: otel-collector-config
namespace: observabilidad
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: {} # añade k8s.namespace, k8s.pod.name, k8s.deployment.name a cada span
tail_sampling: # ver apartado 10
decision_wait: 10s
policies:
- { name: errores, type: status_code, status_code: { status_codes: [ERROR] } }
- { name: lentas, type: latency, latency: { threshold_ms: 1000 } }
- { name: resto, type: probabilistic, probabilistic: { sampling_percentage: 10 } }
exporters:
otlp/jaeger:
endpoint: jaeger-collector.observabilidad.svc.cluster.local:4317
tls: { insecure: true }
prometheus:
endpoint: 0.0.0.0:8889 # métricas derivadas de spans para Prometheus (06-01)
connectors:
spanmetrics: {} # genera métricas RED (llamadas, duración) a partir de los spans
service:
pipelines:
traces: { receivers: [otlp], processors: [memory_limiter, k8sattributes, tail_sampling, batch], exporters: [otlp/jaeger, spanmetrics] }
metrics: { receivers: [spanmetrics], exporters: [prometheus] }receivers.otlp: acepta lo que envían los servicios (gRPC 4317, HTTP 4318).processors:memory_limiterprotege al Collector;k8sattributesetiqueta cada span con el pod y elDeploymentde origen (mismos nombres que en Loki: se cruzan bien);tail_samplingdecide qué trazas conservar después de verlas completas;batchagrupa antes de exportar.exporters.otlp/jaeger: Jaeger v2 acepta OTLP directamente. Cambiarlo por Grafana Tempo es cambiar elendpoint; Tempo se integra mejor con Grafana/Loki, Jaeger tiene la interfaz más conocida. TechCorp empieza con Jaeger (jaeger-querypublicado internamente enjaeger.techcorp.internal).- El connector
spanmetricsgenera métricas RED (traces_span_metrics_calls_total,traces_span_metrics_duration_milliseconds) a partir de los spans; las expone en 8889 para que Prometheus (06-01) las raspe. Es una segunda fuente de RED por servicio y operación, útil sobre todo para las llamadas salientes.
En el ConfigMap de cada servicio (05-02) añadimos OTEL_EXPORTER_OTLP_ENDPOINT y OTEL_SERVICE_NAME; en compose.yaml de local (05-01) un contenedor jaegertracing/all-in-one con OTLP activado y la interfaz en http://localhost:16686 sirve para desarrollar sin Collector.
- Leer una traza:
ped-88213 y el cuello de botella
ped-88213 y el cuello de botellaMarta busca en Jaeger servicio=servicio-pedidos, pedido.id=ped-88213 y abre la traza. Jaeger la muestra como diagrama de Gantt; en forma de tabla (tiempos ficticios pero realistas):
| Servicio | Span | Inicio (ms) | Duración (ms) | Padre |
|---|---|---|---|---|
| gateway | POST /v1/pedidos |
0 | 186 | — |
| gateway | proxy → servicio-pedidos |
3 | 183 | gateway |
| servicio-pedidos | POST /v1/pedidos (http) |
6 | 180 | proxy |
| servicio-pedidos | middleware - jsonParser |
6 | 1 | POST |
| servicio-pedidos | crearPedido |
8 | 176 | POST |
| servicio-pedidos | GET servicio-catalogo:3001/v1/productos (fetch) |
9 | 40 | crearPedido |
| servicio-catalogo | GET /v1/productos |
11 | 36 | fetch catálogo |
| servicio-catalogo | mongodb.find productos |
14 | 9 | GET productos |
| servicio-pedidos | GET servicio-clientes:3004/v1/clientes/c-1024 (fetch) |
9 | 25 | crearPedido |
| servicio-clientes | GET /v1/clientes/:id |
11 | 21 | fetch clientes |
| servicio-clientes | pg.query SELECT clientes |
13 | 6 | GET clientes |
| servicio-pedidos | pg.query BEGIN / INSERT pedidos / INSERT outbox / COMMIT |
52 | 12 | crearPedido |
| servicio-pedidos | (sin span) | 64 | 120 | crearPedido |
Cómo se lee:
- El gateway añade 3 ms de sobrecarga: despreciable.
- Catálogo (40 ms) y Clientes (25 ms) se ejecutan en paralelo (
Promise.alldel apartado 5): empiezan ambos en el ms 9. Si fueran secuenciales, la traza mostraría el segundo empezando al terminar el primero: la primera optimización que una traza suele revelar. - Las consultas a base de datos son rápidas: MongoDB 9 ms, PostgreSQL 6 y 12 ms.
- Sumando: 40 (lo más lento del paralelo) + 12 de
pg= 52 ms de trabajo real, perocrearPedidodura 176 ms. Hay 120 ms sin span entre elCOMMIT(ms 64) y el final del span (ms 184). Es un hueco: tiempo que pasó dentro de nuestro código sin ninguna operación instrumentada. Al mirar el caso de uso, Luis encuentra que tras persistir se llama a una funcióncalcularRecomendacionesheredada del monolito que hace un cálculo síncrono pesado. Bloquea el event loop (06-04) y no aporta nada al pedido. Se elimina y el p95 dePOST /v1/pedidoscae de 250 a 90 ms.
Reglas prácticas de lectura: buscar el span más largo entre hermanos; buscar huecos sin hijos (código propio no instrumentado o event loop bloqueado); comprobar si los hijos son secuenciales cuando podrían ser paralelos; y, en la parte asíncrona, seguir los links de una traza a la siguiente para medir la saga completa (publicar pedido.creado a las 10:21:04.6, consumir pedido.confirmado a las 10:21:07.9: 3,3 s, coherente con el histograma saga_duracion_segundos de 06-01).
- Correlación trazas ↔ logs y trazas ↔ métricas
Trazas ↔ logs. La instrumentación @opentelemetry/instrumentation-pino (activada en el apartado 4) añade automáticamente trace_id, span_id y trace_flags a cada línea de log emitida dentro de un span activo. La línea de 06-01 pasa a ser:
{"nivel":"info","time":"2026-08-15T10:21:04.512Z","servicio":"servicio-pedidos","version":"1.4.2","requestId":"req-01J5Q7X2N9C4M8Z0K1T3V6W8Y","trace_id":"4bf92f3577b34da6a3ce929d0e0e4736","span_id":"00f067aa0ba902b7","pedidoId":"ped-88213","mensaje":"pedido creado"}Si no se quiere depender de esa instrumentación, un mixin en crearLogger hace lo mismo a mano:
const { trace, context } = require('@opentelemetry/api');
// en las opciones de pino():
mixin() {
const span = trace.getSpan(context.active());
if (!span) return {};
const { traceId, spanId } = span.spanContext();
return { trace_id: traceId, span_id: spanId };
}Con eso, en Grafana se configura en el datasource de Loki un derived field: expresión "trace_id":"(\w+)" → enlace a Jaeger/Tempo por trace_id. Y en sentido inverso, desde un span en Jaeger, un enlace a Loki con {namespace="techcorp"} | json | trace_id="…". El operador ya no elige entre logs y trazas: salta.
Trazas ↔ métricas. Los exemplars permiten que un bucket del histograma http_request_duration_seconds lleve adjunto el trace_id de una petición representativa; Grafana los pinta como puntos sobre la gráfica del p95 y un clic abre la traza. Requiere prom-client con soporte de exemplars y --enable-feature=exemplar-storage en Prometheus. Lo dejamos como mención: TechCorp lo activará cuando la pila esté estable; el salto métrica → logs → traza del punto anterior cubre el mismo caso con un paso más.
- Muestreo y coste
Cada POST /v1/pedidos genera unos 12 spans; con la saga, unos 30. A 3.000 pedidos/día es poco, pero el catálogo sirve cientos de miles de GET /v1/productos y en Black Friday ×20. Guardar el 100 % es caro (almacenamiento y CPU en el Collector) y casi siempre innecesario: las trazas "normales" se parecen todas. Por eso se muestrea:
| Estrategia | Dónde decide | Ventaja | Inconveniente |
|---|---|---|---|
| Head sampling | En el servicio, al crear el span raíz | Barato; los servicios descartan antes de exportar | Decide sin saber si la traza acabará en error o será lenta |
| Tail sampling | En el Collector, con la traza completa | Conserva el 100 % de errores y lentas | El Collector debe retener trazas en memoria (decision_wait) y ver todos los spans de una traza en la misma réplica |
TechCorp combina las dos:
- En los servicios,
OTEL_TRACES_SAMPLER=parentbased_traceidratioconOTEL_TRACES_SAMPLER_ARG=0.1en producción (10 % de las trazas raíz;parentbasedsignifica que si el padre fue muestreado, el hijo también: una traza nunca sale a medias) y1.0en dev/staging. - En el Collector, la política
tail_samplingdel apartado 7: todas las trazas con error o de más de 1 s, y el 10 % del resto. Como el head ya redujo al 10 %, el tail actúa sobre ese subconjunto; para que los errores no se pierdan en el head se puede subir el ratio de los servicios críticos (Pedidos, Pagos: 0,5) y dejar el 0,1 para Catálogo. - Retención en Jaeger: 7 días. Los
requestIdde los logs siguen 30 días, así que un incidente antiguo se investiga con logs aunque su traza haya expirado.
Última nota, para cerrar el hilo con 05-05: un service mesh como Istio genera spans en cada sidecar (entrada y salida de cada pod) y los envía al mismo Collector, pero no puede propagar el contexto dentro de la aplicación: entre la petición entrante y la saliente hay código nuestro, y solo nuestro proceso sabe que la segunda es consecuencia de la primera. Si algún día TechCorp adopta el mesh, la aplicación seguirá necesitando exactamente esta lección: reenviar traceparent, y con RabbitMQ, inyectarlo y extraerlo a mano.
Errores Comunes y Consejos
- Cargar el SDK después de Express o
pg. Nada se instrumenta y no hay error. Siempre--require ./src/telemetria.js(oNODE_OPTIONS=--require), nunca unrequiredentro deservidor.jstras otros imports. - Olvidar
span.end()en un camino de error. El span no se exporta y la traza queda "abierta" en Jaeger. Usar siempretry/finallyo la variante con callback que cierra sola. - Perder el contexto en
setTimeout, colas internas oEventEmitter. ElAsyncLocalStoragesigue aawaity promises, pero unsetTimeouto unemitter.onpueden ejecutarse fuera del contexto.context.with(ctx, fn)lo restaura. - Hacer padre-hijo lo que debe ser link. Trazas de 15 minutos con miles de spans y padres que terminan antes que sus hijos. Asíncrono =
links. - Meter datos personales en atributos (
cliente.email, cuerpos de peticiones). Aplican las mismas reglas que a los logs (06-01, 07-03). - Muestreo 100 % en producción "para no perder nada". Se pierde el Collector. Head 10 % + tail para errores.
- Consejo: en local,
jaegertracing/all-in-oneencompose.yamlyOTEL_TRACES_SAMPLER_ARG=1.0; ver la traza de uncurlaPOST /v1/pedidoses la mejor manera de entender la estructura de una petición.
Ejercicios
Ejercicio 1: reintento desde la DLQ sin romper la traza
Un mensaje de pedidos.saga fue a pedidos.saga.dlq y se reprocesa manualmente (06-03). ¿Qué hay que conservar para que el nuevo procesamiento aparezca enlazado a la traza original? ¿Debería ser hijo o link? Escribe el fragmento que crea el span de reproceso.
Ejercicio 2: leer la traza
En una traza de POST /v1/pedidos ves: crearPedido 410 ms; fetch catálogo empieza en el ms 5 y dura 45 ms; fetch clientes empieza en el ms 52 y dura 30 ms; pg 15 ms a partir del ms 84; nada más hasta el ms 410. Enumera los dos problemas y qué harías con cada uno.
Soluciones
Ejercicio 1
Hay que conservar las cabeceras del mensaje original (traceparent, requestId, eventoId) cuando el script mueve el mensaje de la DLQ a la cola principal. El reproceso ocurre mucho después y por iniciativa humana: debe ser link, no hijo. Fragmento del script reprocesarDlq.js:
const ctxOriginal = propagation.extract(context.active(), mensaje.properties.headers);
const spanOriginal = trace.getSpan(ctxOriginal)?.spanContext();
await tracer.startActiveSpan('reprocesar dlq', { kind: SpanKind.PRODUCER, links: spanOriginal ? [{ context: spanOriginal }] : [],
attributes: { 'evento.id': mensaje.properties.messageId, 'dlq.origen': 'pedidos.saga.dlq' } }, async (span) => {
const headers = { ...mensaje.properties.headers, 'x-reproceso': 'manual' };
propagation.inject(context.active(), headers); // sobrescribe traceparent con el del span de reproceso
canal.publish('techcorp.eventos', mensaje.fields.routingKey, mensaje.content, { headers, messageId: mensaje.properties.messageId, persistent: true });
span.end();
});El consumidor recibirá el traceparent del span de reproceso y, a través del link, se podrá llegar a la traza del fallo original.
Ejercicio 2
- Llamadas secuenciales que podrían ser paralelas. Clientes empieza en el ms 52, justo cuando termina Catálogo. Con
Promise.allla fase de validación duraría 45 ms en lugar de 75. Cambio en el caso de uso. - Hueco de ~310 ms sin spans tras el
COMMIT(ms 99 → ms 410). Es código propio no instrumentado o event loop bloqueado. Se localiza envolviendo las funciones sospechosas del caso de uso en spans manuales (o connodejs_eventloop_lag_secondsde 06-01 en ese pod) y se elimina, se hace asíncrono, o se mueve fuera de la petición (a un evento). El resultado esperado:crearPedidoen torno a 60-70 ms.
Conclusión
Con esta lección, TechCorp ve por fin el camino de cada petición. Cada servicio arranca con node --require ./src/telemetria.js, que carga el SDK de OpenTelemetry con instrumentaciones automáticas de http, express, pg, mongodb, amqplib y pino, se identifica con OTEL_SERVICE_NAME y service.version, y exporta por OTLP al Collector; los casos de uso añaden spans manuales (crearPedido, reservarStock, cobrar) con atributos pedido.id/cliente.id y excepciones registradas; el contexto W3C traceparent viaja por HTTP junto al X-Request-Id, se guarda en la fila del outbox y se reinyecta en el relay, y las etapas asíncronas de la saga se unen con links; el Collector aplica k8sattributes, tail_sampling y batch y reexporta a Jaeger y a Prometheus; y trace_id en cada línea de log cierra el triángulo con Loki. Al leer la traza de ped-88213 hemos encontrado un hueco de 120 ms que ninguna métrica delataba. Ya podemos ver los fallos y las lentitudes; la siguiente lección se ocupa de sobrevivirlos: los patrones de gestión de errores y recuperación (timeouts, reintentos, circuit breaker, bulkhead, colas de reintento y DLQ, el vigilante de la saga) que los módulos 2, 3 y 5 fueron remitiendo a 06-03.
Curso de Microservicios
Módulo 1: Introducción a los Microservicios
- Conceptos Básicos de Microservicios
- Ventajas y Desventajas de los Microservicios
- Comparación con la Arquitectura Monolítica
- Cuándo Adoptar Microservicios: Criterios de Decisión
- El Caso Práctico del Curso: la Tienda Online de TechCorp
Módulo 2: Diseño de Microservicios
- Principios de Diseño de Microservicios
- Descomposición de Aplicaciones Monolíticas
- Definición de Bounded Contexts
- Gestión de Datos: una Base de Datos por Servicio
- Consistencia Distribuida: Sagas, CQRS y Event Sourcing
Módulo 3: Comunicación entre Microservicios
- APIs RESTful
- Mensajería Asíncrona
- Protocolos de Comunicación: gRPC, GraphQL
- API Gateway y Backend for Frontend
- Descubrimiento de Servicios y Balanceo de Carga
- Contratos y Versionado de APIs
Módulo 4: Implementación de Microservicios
- Elección de Tecnologías y Herramientas
- Desarrollo de un Microservicio Simple
- Gestión de Configuración
- Integración Práctica: Consumir APIs y Publicar Eventos
- Pruebas en Microservicios: Unitarias, de Integración y de Contrato
Módulo 5: Despliegue y Orquestación
- Contenedores y Docker
- Orquestación con Kubernetes
- CI/CD para Microservicios
- Estrategias de Despliegue: Rolling, Blue-Green y Canary
- Service Mesh: Istio y Linkerd
Módulo 6: Monitoreo y Mantenimiento
- Monitoreo y Logging
- Trazabilidad Distribuida con OpenTelemetry
- Gestión de Errores y Recuperación
- Escalabilidad y Rendimiento
- SLOs, Alertas y Gestión de Incidentes
Módulo 7: Seguridad en Microservicios
- Autenticación y Autorización
- Seguridad en la Comunicación
- Prácticas de Seguridad
- Seguridad en Contenedores y Kubernetes
