In 06-01 we left TechCorp with logs queryable by requestId and a POST /v1/orders p95 of 250 ms. What those signals do not tell us is where those 250 ms go: in the call to Catalog, in the call to Customers, in PostgreSQL, in Express itself? And when the request finishes with a 202, the saga carries on through RabbitMQ in three other services: each one's logs exist, but nobody joins them into a single journey. Distributed traces are the signal that draws that path. This lesson instruments TechCorp's services with OpenTelemetry, propagates context over HTTP and over RabbitMQ, sets up the Collector and Jaeger on Kubernetes, and teaches you to read a real trace of ord-88213.
Contents
- The problem: one request, four services and a saga
- Traces, spans and context:
traceparentand its relationship withX-Request-Id - OpenTelemetry: API, SDK, instrumentations, Collector and exporters
- Instrumenting Node.js:
src/telemetry.jsin the template and in the Dockerfile - Manual spans in the
createOrderuse case - Propagating context through RabbitMQ: outbox, consumers and
links - The Collector and Jaeger on Kubernetes
- Reading a trace:
ord-88213and the bottleneck - Traces ↔ logs and traces ↔ metrics correlation
- Sampling and cost
- The problem: one request, four services and a saga
Let's follow Ana Ruiz's order from the browser:
sequenceDiagram participant G as gateway :8080 participant P as orders-service :3002 participant C as catalog-service :3001 participant K as customers-service :3004 participant R as RabbitMQ participant I as inventory-service :3006 G->>P: POST /v1/orders (X-Request-Id) P->>C: GET /v1/products?ids=p-501,p-777 P->>K: GET /v1/customers/c-1024 P->>P: INSERT order + outbox (pg) P-->>G: 202 Accepted Note over P,R: outbox relay, seconds later P->>R: order.created R->>I: inventory.orders I->>R: stock.reserved
With what we have, each hop leaves an access log with its responseTime and an observation in the 06-01 histogram. But to answer "why did this request take 1.8 s?" you would have to open Loki, search for the requestId, write down the timings of four services by hand and subtract them. And the asynchronous part does not even share the requestId naturally: the Inventory consumer receives a message, not a request.
A trace does that work automatically: it records every operation as a span with start, duration, attributes and parent, and ties them together with a common identifier. Viewing them in Jaeger is seeing the sequenceDiagram above with real timings.
- Traces, spans and context:
traceparent and its relationship with X-Request-Id
traceparent and its relationship with X-Request-Id| Concept | Definition | At TechCorp |
|---|---|---|
| Trace | The complete tree of operations triggered by one action; identified by a 128-bit trace_id |
Everything that happens as a result of a POST /v1/orders, saga included |
| Span | One operation with a name, start, end, attributes, events and status; it has a span_id and a parent_span_id |
POST /v1/orders in Orders, GET /v1/products in Catalog, pg.query INSERT, createOrder |
| Trace context | The pair (trace_id, parent span_id) plus flags, which travels between processes |
HTTP header traceparent; AMQP header traceparent |
| Propagation | Injecting the context on the way out and extracting it on the way in | Automatic in fetch/Express; manual in RabbitMQ (section 6) |
The standard format is W3C Trace Context: a traceparent header with four dash-separated fields:
traceparent: 00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01
│ │ │ │
│ trace_id (32 hex) parent span_id flags (01 = sampled)
versionAnd an optional tracestate for vendor data. Everything that speaks HTTP at TechCorp (gateway, services, fetch) must forward traceparent just as it forwarded X-Request-Id.
Does traceparent replace X-Request-Id? No: they coexist with different roles.
X-Request-Id (03-01) |
traceparent (W3C) |
|
|---|---|---|
| Who generates it | requestIdMiddleware() or the gateway |
The OpenTelemetry SDK |
| What it identifies | The business request, human-readable (req-01J5Q…) |
The technical trace and the parent span |
| Where you see it | Logs, RFC 7807 error responses, customer support | Jaeger, and as trace_id in logs (section 9) |
| Sampling | Always present | May not be sampled (section 10) |
Decision: both are kept. The requestId is what a support operator asks the customer for and searches in Loki; the trace_id is what the developer opens in Jaeger. Both appear on every log line, so going from one to the other is one query.
- OpenTelemetry: API, SDK, instrumentations, Collector and exporters
OpenTelemetry (OTel) is the CNCF standard that unifies how traces, metrics and logs are generated and transported, independently of the vendor that stores them. Its pieces:
flowchart LR
subgraph process [Node.js process: orders-service]
API[OTel API<br/>tracer.startActiveSpan] --> SDK[SDK<br/>processors + sampling]
AUTO[Automatic instrumentations<br/>http, express, pg, mongodb, amqplib] --> SDK
SDK --> EXP[OTLP exporter]
end
EXP -->|OTLP gRPC :4317| COL[OpenTelemetry Collector<br/>receivers → processors → exporters]
COL --> J[(Jaeger / Tempo)]
COL --> PR[(Prometheus)]
- API: what the application code uses (
trace.getTracer,startActiveSpan,propagation.inject). It is stable and independent of the implementation: if no SDK is loaded, it does nothing. - SDK: the implementation that actually creates the spans, applies sampling, groups them into batches and hands them to the exporter.
- Automatic instrumentations: monkey-patching of well-known libraries to create spans without touching our code:
http(incoming and outgoing, including Node 20's globalfetchvia@opentelemetry/instrumentation-undici),express(one span per middleware/route),pg,mongodb,amqplib. - Exporters: send over OTLP (OTel's native protocol, gRPC on 4317 or HTTP on 4318) to the Collector, or directly to a backend.
- Collector: an intermediate process that receives, processes (batching, tail sampling, enrichment with Kubernetes metadata) and re-exports to one or more destinations. It decouples the application from the backend: swapping Jaeger for Tempo means changing the Collector configuration, not redeploying seven services.
- Instrumenting Node.js:
src/telemetry.js in the template and in the Dockerfile
src/telemetry.js in the template and in the DockerfileAutomatic instrumentations patch modules when they are loaded, so the SDK must be initialized before Express, pg or amqplib. The clean way is a separate file loaded with --require, without touching server.js:
npm install @opentelemetry/sdk-node @opentelemetry/auto-instrumentations-node \
@opentelemetry/exporter-trace-otlp-grpc @opentelemetry/resources @opentelemetry/semantic-conventions// src/telemetry.js — runs before the application
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, // "orders-service"
[ATTR_SERVICE_VERSION]: process.env.SERVICE_VERSION, // "1.4.2", the same one the 06-01 logger uses
'deployment.environment': process.env.ENVIRONMENT || 'local'
}),
traceExporter: new OTLPTraceExporter(), // reads OTEL_EXPORTER_OTLP_ENDPOINT
instrumentations: [
getNodeAutoInstrumentations({
'@opentelemetry/instrumentation-fs': { enabled: false }, // noise: every file read would be a span
'@opentelemetry/instrumentation-http': {
ignoreIncomingRequestHook: (req) => req.url.startsWith('/health') || req.url === '/metrics'
},
'@opentelemetry/instrumentation-express': { enabled: true },
'@opentelemetry/instrumentation-pg': { enhancedDatabaseReporting: false }, // do not include SQL parameter values
'@opentelemetry/instrumentation-mongodb': { enabled: true },
'@opentelemetry/instrumentation-amqplib': { enabled: true },
'@opentelemetry/instrumentation-pino': { enabled: true } // trace_id/span_id in the logs (section 9)
})
]
});
sdk.start();
process.on('SIGTERM', () => {
sdk.shutdown().catch(() => {}).finally(() => process.exit(0)); // flush the span batch before dying
});Explanation:
resourcedescribes who emits:service.nameis what Jaeger shows as the service;service.versionlets us compare the canary (05-04) with the stable version.OTLPTraceExporter()with no arguments takes its destination fromOTEL_EXPORTER_OTLP_ENDPOINT, which goes into the service'sConfigMap(05-02).getNodeAutoInstrumentationsenables the whole catalog; we disablefsand exclude the probes and/metricsfrom incoming spans for the same reason as in the 06-01 logs.enhancedDatabaseReporting: false: thepgspan includes the SQL statement but not the parameters: Ana Ruiz's data must not travel to Jaeger (same rule as log redaction).- The
SIGTERMhandler matters: spans are exported in batches every few seconds; withoutshutdown()the last batch of a pod that dies during a rolling update is lost. It coexists with the server's graceful shutdown from 04-02.
Standard OTel environment variables that we add to the configuration template (04-03) and to the ConfigMap:
| Variable | Value in techcorp |
Purpose |
|---|---|---|
OTEL_SERVICE_NAME |
orders-service |
Service name in the traces |
OTEL_EXPORTER_OTLP_ENDPOINT |
http://otel-collector.observability.svc.cluster.local:4317 |
OTLP destination |
OTEL_TRACES_SAMPLER / OTEL_TRACES_SAMPLER_ARG |
parentbased_traceidratio / 0.1 in prod, 1.0 in dev |
Sampling (section 10) |
OTEL_PROPAGATORS |
tracecontext,baggage (default) |
W3C Trace Context |
SERVICE_VERSION |
Injected by Kustomize with the image tag | service.version and the logger's version field |
Startup changes in package.json and in the Dockerfile from 05-01:
"scripts": {
"start": "node --require ./src/telemetry.js src/server.js",
"dev": "nodemon -r dotenv/config --require ./src/telemetry.js src/server.js"
}# last stage of the multi-stage Dockerfile (05-01)
USER node
CMD ["node", "--require", "./src/telemetry.js", "src/server.js"]--require loads the module before the first line of server.js; from then on, every Express request, every outgoing fetch and every pg query produces spans with no extra code. With this, a POST /v1/orders already generates the gateway → orders → catalog/customers → pg trace in Jaeger. What is missing is what automatic instrumentation does not know: where our business logic starts and ends.
- Manual spans in the
createOrder use case
createOrder use caseA span of our own marks the business operation and adds attributes that can later be searched in Jaeger. We modify the use case from 04-04:
// orders-service/src/use-cases/createOrder.js
const { trace, SpanStatusCode } = require('@opentelemetry/api');
const tracer = trace.getTracer('orders-service');
function createCreateOrderUseCase({ repository, catalogClient, customersClient, logger, metrics }) {
return async function createOrder(data, { requestId }) {
return tracer.startActiveSpan('createOrder', async (span) => {
span.setAttribute('customer.id', data.customerId);
span.setAttribute('order.lines', data.lines.length);
span.setAttribute('techcorp.request_id', requestId);
try {
const [products, customer] = await Promise.all([
catalogClient.getProducts(data.lines.map((l) => l.productId), { requestId }),
customersClient.getCustomer(data.customerId, { requestId })
]);
const order = await repository.saveWithOutbox(buildOrder(data, products, customer));
span.setAttribute('order.id', order.id);
span.setAttribute('order.total', order.total);
span.addEvent('order.persisted');
metrics.created.inc();
return order;
} catch (err) {
span.recordException(err);
span.setStatus({ code: SpanStatusCode.ERROR, message: err.code || err.message });
throw err;
} finally {
span.end();
}
});
};
}trace.getTracer('orders-service'): obtains a tracer from the API. We only import@opentelemetry/api: the use case knows nothing about the SDK, and in the 04-05 tests, with no SDK loaded, the spans are "no-op".startActiveSpan('createOrder', fn): creates the span as a child of the active one (Express'sPOST /v1/ordersspan) and keeps it active duringfn, so that thefetchcalls to Catalog and Customers and thepgINSERThang off it automatically. That is the "active context" trick: automatic instrumentation looks for the active span in Node'sAsyncLocalStorageand attaches to it.- Attributes follow the
domain.fieldconvention (order.id,customer.id); they are high-cardinality values, which in traces are welcome (unlike the metric labels of 06-01): Jaeger lets you search fororder.id=ord-88213. addEventadds a timestamped marker inside the span;recordException+setStatus(ERROR)make the trace show up in red and with the stack (without this, aBusinessErrorthat ends in a 503 would look like a green span that finishes early); andspan.end()goes infinallyalways: an unclosed span is never exported.
The same three gestures (startActiveSpan, attributes, recordException) are applied in Inventory's createReserveStockUseCase and in Payments' charge.
- Propagating context through RabbitMQ: outbox, consumers and
links
linksThe amqplib instrumentation injects traceparent into the headers when publishing inside an active span and extracts it when consuming. At TechCorp there is a trap: the order.created event is not published by the use case but by the outbox relay (04-04), at another moment and with no active span related to the request. If we do nothing, the saga starts a brand-new trace unrelated to the POST.
Solution: store the context in the outbox row when writing it and restore it when publishing.
// orders-service/src/repositories/orderRepository.js — when inserting into outbox
const { propagation, context } = require('@opentelemetry/api');
function contextHeaders() {
const carrier = {};
propagation.inject(context.active(), carrier); // writes traceparent (and tracestate) into the object
return carrier; // { traceparent: '00-4bf9…-00f0…-01' }
}
// INSERT INTO outbox (id, type, payload, headers) VALUES ($1, $2, $3, $4)
// headers = { requestId, ...contextHeaders() }propagation.inject takes the active context (we are inside createOrder, section 5) and writes the W3C headers into any object; that object is stored in the headers column (JSONB) next to the requestId we were already storing in 03-02.
In the relay (messaging/outboxRelay.js), when publishing each row:
const { propagation, context, trace, SpanKind } = require('@opentelemetry/api');
const tracer = trace.getTracer('orders-service');
async function publishRow(row) {
const originContext = propagation.extract(context.active(), row.headers); // rebuilds the stored context
const originSpan = trace.getSpan(originContext)?.spanContext();
await tracer.startActiveSpan(`publish ${row.type}`, {
kind: SpanKind.PRODUCER,
links: originSpan ? [{ context: originSpan }] : [], // link, not parent
attributes: { 'messaging.system': 'rabbitmq', 'messaging.destination.name': 'techcorp.events', 'event.id': row.id, 'event.type': row.type }
}, async (span) => {
const headers = { ...row.headers };
propagation.inject(context.active(), headers); // traceparent of the PRODUCER span
channel.publish('techcorp.events', row.type, Buffer.from(JSON.stringify(row.payload)), { headers, messageId: row.id, persistent: true });
span.end();
});
}There is a design decision here. We could make the publish span a child of the original POST /v1/orders, but that span ended seconds ago: the trace would show a child starting after its parent finished, and the traces of 15-minute sagas would be unmanageable. OTel has the concept of a link for this: the publish span belongs to its own trace (the relay's/the saga's) but links to the original span. Jaeger shows links and lets you jump from one trace to the other. The rule at TechCorp:
- Inside a synchronous (HTTP) request: parent-child relationship.
- Between the request and asynchronous processing (outbox, consumers): a link to the originating span and one trace per event.
- Inside a consumer, everything it triggers (queries, publishing the next event) is a child of the consume span, so each step of the saga is a small trace linked to the previous one:
POST⇢publish order.created⇢consume order.created(Inventory) ⇢publish stock.reserved⇢ …
In the saga consumer (messaging/sagaConsumer.js) the amqplib instrumentation already creates an orders.saga process span with SpanKind.CONSUMER from the traceparent in the headers. We only add attributes and, if the message comes from a retry or from the DLQ (06-03), we keep the headers so as not to break the chain. When a service publishes without going through the outbox (Inventory publishes stock.reserved inside the consume span), automatic injection is enough.
- The Collector and Jaeger on Kubernetes
The Platform team deploys the Collector in the observability namespace as a Deployment (two replicas behind an otel-collector Service with ports 4317/4318) and its configuration in a ConfigMap:
apiVersion: v1
kind: ConfigMap
metadata:
name: otel-collector-config
namespace: observability
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: {} # adds k8s.namespace, k8s.pod.name, k8s.deployment.name to every span
tail_sampling: # see section 10
decision_wait: 10s
policies:
- { name: errors, type: status_code, status_code: { status_codes: [ERROR] } }
- { name: slow, type: latency, latency: { threshold_ms: 1000 } }
- { name: rest, type: probabilistic, probabilistic: { sampling_percentage: 10 } }
exporters:
otlp/jaeger:
endpoint: jaeger-collector.observability.svc.cluster.local:4317
tls: { insecure: true }
prometheus:
endpoint: 0.0.0.0:8889 # span-derived metrics for Prometheus (06-01)
connectors:
spanmetrics: {} # generates RED metrics (calls, duration) from the spans
service:
pipelines:
traces: { receivers: [otlp], processors: [memory_limiter, k8sattributes, tail_sampling, batch], exporters: [otlp/jaeger, spanmetrics] }
metrics: { receivers: [spanmetrics], exporters: [prometheus] }receivers.otlp: accepts what the services send (gRPC 4317, HTTP 4318).processors:memory_limiterprotects the Collector;k8sattributestags every span with the originating pod andDeployment(same names as in Loki: they cross-reference well);tail_samplingdecides which traces to keep after seeing them complete;batchgroups before exporting.exporters.otlp/jaeger: Jaeger v2 accepts OTLP directly. Swapping it for Grafana Tempo means changing theendpoint; Tempo integrates better with Grafana/Loki, Jaeger has the better-known UI. TechCorp starts with Jaeger (jaeger-querypublished internally atjaeger.techcorp.internal).- The
spanmetricsconnector generates RED metrics (traces_span_metrics_calls_total,traces_span_metrics_duration_milliseconds) from the spans; it exposes them on 8889 so that Prometheus (06-01) scrapes them. It is a second source of RED per service and operation, useful above all for outgoing calls.
In each service's ConfigMap (05-02) we add OTEL_EXPORTER_OTLP_ENDPOINT and OTEL_SERVICE_NAME; in the local compose.yaml (05-01) a jaegertracing/all-in-one container with OTLP enabled and the UI at http://localhost:16686 is enough for developing without a Collector.
- Reading a trace:
ord-88213 and the bottleneck
ord-88213 and the bottleneckMarta searches Jaeger for service=orders-service, order.id=ord-88213 and opens the trace. Jaeger shows it as a Gantt chart; in table form (fictional but realistic timings):
| Service | Span | Start (ms) | Duration (ms) | Parent |
|---|---|---|---|---|
| gateway | POST /v1/orders |
0 | 186 | — |
| gateway | proxy → orders-service |
3 | 183 | gateway |
| orders-service | POST /v1/orders (http) |
6 | 180 | proxy |
| orders-service | middleware - jsonParser |
6 | 1 | POST |
| orders-service | createOrder |
8 | 176 | POST |
| orders-service | GET catalog-service:3001/v1/products (fetch) |
9 | 40 | createOrder |
| catalog-service | GET /v1/products |
11 | 36 | fetch catalog |
| catalog-service | mongodb.find products |
14 | 9 | GET products |
| orders-service | GET customers-service:3004/v1/customers/c-1024 (fetch) |
9 | 25 | createOrder |
| customers-service | GET /v1/customers/:id |
11 | 21 | fetch customers |
| customers-service | pg.query SELECT customers |
13 | 6 | GET customers |
| orders-service | pg.query BEGIN / INSERT orders / INSERT outbox / COMMIT |
52 | 12 | createOrder |
| orders-service | (no span) | 64 | 120 | createOrder |
How to read it:
- The gateway adds 3 ms of overhead: negligible.
- Catalog (40 ms) and Customers (25 ms) run in parallel (
Promise.allfrom section 5): both start at ms 9. If they were sequential, the trace would show the second one starting when the first finishes: the first optimization a trace usually reveals. - Database queries are fast: MongoDB 9 ms, PostgreSQL 6 and 12 ms.
- Adding up: 40 (the slowest of the parallel pair) + 12 for
pg= 52 ms of real work, butcreateOrderlasts 176 ms. There are 120 ms without a span between theCOMMIT(ms 64) and the end of the span (ms 184). It is a gap: time spent inside our code without any instrumented operation. Looking at the use case, Luis finds that after persisting, acomputeRecommendationsfunction inherited from the monolith is called, which does a heavy synchronous computation. It blocks the event loop (06-04) and contributes nothing to the order. It is removed and the p95 ofPOST /v1/ordersdrops from 250 to 90 ms.
Practical reading rules: look for the longest span among siblings; look for gaps with no children (uninstrumented own code or a blocked event loop); check whether children are sequential when they could be parallel; and, in the asynchronous part, follow the links from one trace to the next to measure the whole saga (publish order.created at 10:21:04.6, consume order.confirmed at 10:21:07.9: 3.3 s, consistent with the saga_duration_seconds histogram from 06-01).
- Traces ↔ logs and traces ↔ metrics correlation
Traces ↔ logs. The @opentelemetry/instrumentation-pino instrumentation (enabled in section 4) automatically adds trace_id, span_id and trace_flags to every log line emitted inside an active span. The line from 06-01 becomes:
{"level":"info","time":"2026-08-15T10:21:04.512Z","service":"orders-service","version":"1.4.2","requestId":"req-01J5Q7X2N9C4M8Z0K1T3V6W8Y","trace_id":"4bf92f3577b34da6a3ce929d0e0e4736","span_id":"00f067aa0ba902b7","orderId":"ord-88213","message":"order created"}If you would rather not depend on that instrumentation, a mixin in createLogger does the same by hand:
const { trace, context } = require('@opentelemetry/api');
// in the pino() options:
mixin() {
const span = trace.getSpan(context.active());
if (!span) return {};
const { traceId, spanId } = span.spanContext();
return { trace_id: traceId, span_id: spanId };
}With that, in Grafana you configure a derived field on the Loki datasource: expression "trace_id":"(\w+)" → link to Jaeger/Tempo by trace_id. And in the opposite direction, from a span in Jaeger, a link to Loki with {namespace="techcorp"} | json | trace_id="…". The operator no longer chooses between logs and traces: they jump.
Traces ↔ metrics. Exemplars allow a bucket of the http_request_duration_seconds histogram to carry the trace_id of a representative request; Grafana draws them as dots over the p95 graph and one click opens the trace. It requires prom-client with exemplar support and --enable-feature=exemplar-storage in Prometheus. We leave it as a mention: TechCorp will enable it once the stack is stable; the metric → logs → trace jump from the previous point covers the same case with one extra step.
- Sampling and cost
Each POST /v1/orders generates about 12 spans; with the saga, about 30. At 3,000 orders/day that is little, but the catalog serves hundreds of thousands of GET /v1/products and on Black Friday ×20. Keeping 100% is expensive (storage and CPU in the Collector) and almost always unnecessary: "normal" traces all look alike. That is why you sample:
| Strategy | Where it decides | Advantage | Drawback |
|---|---|---|---|
| Head sampling | In the service, when the root span is created | Cheap; services discard before exporting | Decides without knowing whether the trace will end in error or be slow |
| Tail sampling | In the Collector, with the complete trace | Keeps 100% of errors and slow traces | The Collector must hold traces in memory (decision_wait) and see all spans of a trace on the same replica |
TechCorp combines both:
- In the services,
OTEL_TRACES_SAMPLER=parentbased_traceidratiowithOTEL_TRACES_SAMPLER_ARG=0.1in production (10% of root traces;parentbasedmeans that if the parent was sampled, so is the child: a trace never comes out half-done) and1.0in dev/staging. - In the Collector, the
tail_samplingpolicy from section 7: all traces with an error or longer than 1 s, and 10% of the rest. Since the head already cut down to 10%, the tail acts on that subset; so that errors are not lost at the head, the ratio of the critical services can be raised (Orders, Payments: 0.5) while leaving 0.1 for Catalog. - Retention in Jaeger: 7 days. The
requestIds in the logs stay 30 days, so an old incident is investigated with logs even though its trace has expired.
One last note, to close the thread with 05-05: a service mesh like Istio generates spans at every sidecar (entry and exit of each pod) and sends them to the same Collector, but it cannot propagate context inside the application: between the incoming request and the outgoing one there is our code, and only our process knows that the second is a consequence of the first. If TechCorp ever adopts the mesh, the application will still need exactly this lesson: forwarding traceparent, and with RabbitMQ, injecting and extracting it by hand.
Common Mistakes and Tips
- Loading the SDK after Express or
pg. Nothing gets instrumented and there is no error. Always--require ./src/telemetry.js(orNODE_OPTIONS=--require), never arequireinsideserver.jsafter other imports. - Forgetting
span.end()on an error path. The span is not exported and the trace stays "open" in Jaeger. Always usetry/finallyor the callback variant that closes it by itself. - Losing context in
setTimeout, internal queues orEventEmitter.AsyncLocalStoragefollowsawaitand promises, but asetTimeoutor anemitter.onmay run outside the context.context.with(ctx, fn)restores it. - Making parent-child what should be a link. 15-minute traces with thousands of spans and parents that finish before their children. Asynchronous =
links. - Putting personal data in attributes (
customer.email, request bodies). The same rules as for logs apply (06-01, 07-03). - 100% sampling in production "so nothing is lost". What is lost is the Collector. Head 10% + tail for errors.
- Tip: locally,
jaegertracing/all-in-oneincompose.yamlandOTEL_TRACES_SAMPLER_ARG=1.0; seeing the trace of acurltoPOST /v1/ordersis the best way to understand the structure of a request.
Exercises
Exercise 1: retrying from the DLQ without breaking the trace
A message from orders.saga went to orders.saga.dlq and is reprocessed manually (06-03). What must be preserved so that the new processing shows up linked to the original trace? Should it be a child or a link? Write the snippet that creates the reprocess span.
Exercise 2: reading the trace
In a POST /v1/orders trace you see: createOrder 410 ms; fetch catalog starts at ms 5 and lasts 45 ms; fetch customers starts at ms 52 and lasts 30 ms; pg 15 ms starting at ms 84; nothing else until ms 410. List the two problems and what you would do about each one.
Solutions
Exercise 1
You must preserve the original message headers (traceparent, requestId, eventId) when the script moves the message from the DLQ to the main queue. The reprocessing happens much later and on human initiative: it must be a link, not a child. Snippet from the reprocessDlq.js script:
const originalCtx = propagation.extract(context.active(), message.properties.headers);
const originalSpan = trace.getSpan(originalCtx)?.spanContext();
await tracer.startActiveSpan('reprocess dlq', { kind: SpanKind.PRODUCER, links: originalSpan ? [{ context: originalSpan }] : [],
attributes: { 'event.id': message.properties.messageId, 'dlq.source': 'orders.saga.dlq' } }, async (span) => {
const headers = { ...message.properties.headers, 'x-reprocess': 'manual' };
propagation.inject(context.active(), headers); // overwrites traceparent with the reprocess span's
channel.publish('techcorp.events', message.fields.routingKey, message.content, { headers, messageId: message.properties.messageId, persistent: true });
span.end();
});The consumer will receive the reprocess span's traceparent and, through the link, the trace of the original failure can be reached.
Exercise 2
- Sequential calls that could be parallel. Customers starts at ms 52, right when Catalog finishes. With
Promise.allthe validation phase would take 45 ms instead of 75. Change in the use case. - A ~310 ms gap with no spans after the
COMMIT(ms 99 → ms 410). It is uninstrumented own code or a blocked event loop. It is located by wrapping the suspicious functions of the use case in manual spans (or withnodejs_eventloop_lag_secondsfrom 06-01 on that pod) and then removed, made asynchronous, or moved out of the request (into an event). Expected result:createOrderaround 60-70 ms.
Conclusion
With this lesson, TechCorp finally sees the path of every request. Each service starts with node --require ./src/telemetry.js, which loads the OpenTelemetry SDK with automatic instrumentations for http, express, pg, mongodb, amqplib and pino, identifies itself with OTEL_SERVICE_NAME and service.version, and exports over OTLP to the Collector; the use cases add manual spans (createOrder, reserveStock, charge) with order.id/customer.id attributes and recorded exceptions; the W3C traceparent context travels over HTTP alongside X-Request-Id, is stored in the outbox row and re-injected in the relay, and the asynchronous stages of the saga are joined with links; the Collector applies k8sattributes, tail_sampling and batch and re-exports to Jaeger and Prometheus; and trace_id on every log line closes the triangle with Loki. Reading the trace of ord-88213 we found a 120 ms gap that no metric gave away. We can now see failures and slowness; the next lesson is about surviving them: the error handling and recovery patterns (timeouts, retries, circuit breaker, bulkhead, retry queues and DLQ, the saga watchdog) that modules 2, 3 and 5 kept deferring to 06-03.
Microservices Course
Module 1: Introduction to Microservices
- Basic Concepts of Microservices
- Advantages and Disadvantages of Microservices
- Comparison with the Monolithic Architecture
- When to Adopt Microservices: Decision Criteria
- The Course Case Study: TechCorp's Online Store
Module 2: Microservice Design
- Microservice Design Principles
- Decomposing Monolithic Applications
- Defining Bounded Contexts
- Data Management: One Database per Service
- Distributed Consistency: Sagas, CQRS and Event Sourcing
Module 3: Communication between Microservices
- RESTful APIs
- Asynchronous Messaging
- Communication Protocols: gRPC, GraphQL
- API Gateway and Backend for Frontend
- Service Discovery and Load Balancing
- API Contracts and Versioning
Module 4: Implementing Microservices
- Choosing Technologies and Tools
- Building a Simple Microservice
- Configuration Management
- Hands-On Integration: Consuming APIs and Publishing Events
- Testing Microservices: Unit, Integration and Contract Tests
Module 5: Deployment and Orchestration
- Containers and Docker
- Orchestration with Kubernetes
- CI/CD for Microservices
- Deployment Strategies: Rolling, Blue-Green and Canary
- Service Mesh: Istio and Linkerd
Module 6: Monitoring and Maintenance
- Monitoring and Logging
- Distributed Tracing with OpenTelemetry
- Error Handling and Recovery
- Scalability and Performance
- SLOs, Alerts and Incident Management
Module 7: Security in Microservices
- Authentication and Authorization
- Communication Security
- Security Practices
- Container and Kubernetes Security
