Observability with OpenTelemetry
During last year's festival outage, Mangrove's dashboards said checkout was failing but could not say why, and it took far too long to find the payment retries behind it. This lesson builds the telemetry that answers new questions during an outage, with OpenTelemetry from the app to the Collector, at a cost you can defend.
By the end you can
- Decide which context goes on spans and logs and which on metric labels, using a cardinality budget
- Instrument a NestJS checkout with auto instrumentation plus manual spans, attributes and events
- Carry trace context across HTTP and a job queue, using links for asynchronous work
- Configure a Collector pipeline with memory_limiter, attribute scrubbing, tail sampling and batch
- Jump from a metric spike to a trace with exemplars, and from a trace to its logs with trace_id
The everyday picture
Think of a parcel company. Every parcel gets a label with a tracking number. At each depot a worker scans the label and adds a line: the time, the depot, and a note such as "box damaged" or "held for customs". The label travels with the parcel, so every line ends up on the same history page.
The depot also has a whiteboard that counts parcels per hour and how many are late. The whiteboard is cheap and tells you at a glance that something is wrong. It cannot tell you why the parcel to one street in one town was late. For that you open the tracking history, which has all the details.
Nobody keeps a photo of every parcel. They keep photos of damaged ones and a few normal ones for comparison, and they decide that at the end of the journey, once they know how it went.
OpenTelemetry works the same way. The tracking number is the trace id. Each scan line is a span with attributes and events. The whiteboard is your metrics. Choosing which photos to keep at the end is tail sampling.
Questions you did not plan for
In Observability: logs, metrics and traces you met the three signals and RED dashboards. Those dashboards answer the questions you thought of in advance. An outage asks new ones: is it only card payments, only app version 5.2, only stores in one city, only orders with more than 40 items? You are observable when you can answer such a question with the data you already send, without writing and deploying new code in the middle of the incident.
That needs high-cardinality context: fields with many possible values, such as store id, order id or app version. The trap is where you put it. In a metric, every new combination of label values creates a new time series, and the metrics system pays for each one all the time. On a span or a log line, the same field costs a few bytes on that one record.
A quick count shows the difference. Mangrove's order service has a latency histogram with about 40 route and method pairs, about 6 status codes, and 12 series per histogram (10 buckets, a sum and a count). That is 40 × 6 × 12 = 2,880 series per pod, or 34,560 across 12 pods. Add a payment_method label with 3 values (card, wallet, cash on delivery) and you have about 104,000. Add store_id with 320 values instead and you have about 11 million series. The first is fine, the second is expensive, and the third can take down a Prometheus server.
So the team sets a cardinality budget: for example, at most 50,000 series per service, checked in code review. Prometheus can enforce it per target with sample_limit in the scrape config. The OpenTelemetry metrics SDK specification also defines a per-metric cardinality limit (2,000 by default) that folds extra combinations into one overflow series; support differs by language, so check yours. Everything above the budget goes on spans and logs, where you can group by store id later in the trace tool.
Quick check
Support asks for a customer_id label on the checkout latency histogram, so they can see one customer's slow checkouts. What is the better design?
With 2 million app customers, a customer label creates millions of series even on one route, and the scrape interval does not change the series count. Per-customer detail belongs on spans and logs, where each record pays only for itself. Hash or drop personal ids so the telemetry does not become a store of personal data.
API, SDK and instrumentation
OpenTelemetry splits into two layers. The API (@opentelemetry/api in Node) is what code calls: get a tracer, start a span, set an attribute. On its own it does nothing, and every call is a cheap no-op. The SDK is what the application installs at startup. It samples, batches and exports. A shared library, such as Mangrove's payment client package, depends only on the API. The service that uses it installs the SDK and decides where data goes. So a library never forces an exporter or a vendor on the services that use it.
Instrumentation comes in two kinds. Auto instrumentation (@opentelemetry/auto-instrumentations-node) patches http, Express, NestJS, pg, ioredis and pino at startup. You get a span for every incoming request, every outgoing call and every query, with context passed along for you. It does not know your business. It cannot tell you that this request was a cash-on-delivery order with 43 items that needed two payment attempts. For that you add manual spans and attributes in the code that knows.
A span carries three kinds of detail. Attributes describe the whole operation: mangrove.payment.method = "wallet". Events mark moments inside it: "stock reserved" at 112 ms, "retry scheduled" at 2,040 ms. The status says whether it failed. Keep span names low-cardinality, such as checkout.place_order, and put ids in attributes. A name with an order id in it makes every span a group of one.
Two kinds of shared naming make data from different teams line up. Semantic conventions are standard attribute names: http.request.method, http.route, http.response.status_code. Auto instrumentation uses them, so one query works across every service. Your own attributes get your own prefix, such as mangrove., so they never collide with a future standard name. Resource attributes describe the process itself and ride on every span, metric and log it sends: service.name, service.version and the environment. The environment key was deployment.environment and is deployment.environment.name in newer convention versions; pick one and use it everywhere. Without these, staging traffic mixes into production dashboards and you cannot tell which release a slow span came from.
Following one checkout across services and the queue
A trace only helps if every service adds its spans to the same trace. That hand-off is context propagation. Over HTTP it uses the W3C traceparent header, which has four parts separated by dashes:
traceparent: 00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01
| | | |
| trace id: 32 hex, whole journey parent span id: flags: bit 0 set
version 00 16 hex, caller means "sampled"The ids are lowercase hex, and an id of all zeros is invalid. The flags are a bit field. Only the lowest bit means "sampled", so 01 and 03 both mean the caller recorded this trace. With a parentbased sampler (sd-31 set one up), each service follows that bit, so a trace is never recorded in the gateway and dropped in the payment service.
HTTP and gRPC are covered by auto instrumentation. A job queue usually is not. When the order service puts a "send receipt" job on the queue, nothing carries the context unless you put it into the job yourself: propagation.inject writes a traceparent into the job data, and the worker calls propagation.extract to read it back.
Then you choose how the worker's span relates to the order. The job may run seconds or minutes later, and a batch job may handle 500 orders at once. Making it a child would stretch the checkout trace over hours, or give one span 500 parents. So the worker starts a new trace and adds a span link to the producer's span context. The trace tool shows the link as a jump between the two traces, and each trace keeps a sensible length.
Baggage is a second header that carries your own key-value pairs to every downstream service, such as mangrove.channel=app. It is not added to spans automatically, and it travels in plain headers to every service you call, including outside providers.
Quick check
The payment service uses a parentbased_traceidratio sampler set to 0.2. A request arrives with traceparent: 00-…-…-00. What does the payment service do with its spans for this request?
A parent-based sampler follows the sampled bit of the incoming context, and 00 means the caller did not sample. The ratio only applies to new traces that arrive with no parent. Flags of 00 are valid; only all-zero ids make the header invalid.
The Collector pipeline, sampling and cost
Services can send telemetry straight to a backend, but most teams put an OpenTelemetry Collector in between. It speaks OTLP, the standard protocol, on port 4317 for gRPC and 4318 for HTTP. Each pipeline has three stages. Receivers take data in. Processors change it in order. Exporters send it to the backends. Because the apps only know "send OTLP to the Collector", you can change sampling, scrub data or switch vendors without redeploying a single service.
Four processors matter most for checkout, and their order matters too:
memory_limitergoes first. When the Collector nears its memory limit it refuses new data, so senders back off and retry, instead of the Collector crashing and losing everything it holds.attributesdeletes or hashes fields that must not be stored, such as an authorization header or a raw customer id. One rule here protects every service.tail_samplingdecides which traces to keep, once they are complete.batchgoes near the end and groups spans into fewer, bigger sends.
Head sampling decides at the first span, in the SDK. It is cheap, but blind: at that moment nobody knows whether the checkout will fail. Sample 10% at the head and you throw away 90% of your failed checkouts too. Tail sampling waits until the trace is complete, for example 10 seconds after its first span, then applies policies: keep every trace with an error, every trace slower than 2 seconds, every trace with more than one payment attempt, and 5% of the rest.
The cost difference is large. Say the checkout path sends roughly 10,000 spans a second at the festival peak. Keeping all of them is about 36 million spans an hour. If roughly 1% of traces have errors and 1% are slow, the policies above keep about 7%, around 2.5 million spans an hour, and that 7% includes every trace you will want during an incident. The price is memory: a 10-second wait holds about 100,000 spans in the Collector, which is why memory_limiter runs before it.
Tail sampling has one hard rule: all spans of a trace must reach the same Collector, or each copy sees half a trace and decides wrongly. With several Collectors behind a round-robin load balancer, failed traces arrive with missing spans. The fix is two tiers. A first tier of Collectors uses the loadbalancing exporter with routing_key: traceID, so every span of one trace goes to the same second-tier Collector, which runs tail_sampling.
Quick check
Ken wants every failed checkout trace kept during the festival, but the trace bill cut by about 90%. Which setup does that?
Head sampling decides before the outcome is known, so 10% at the head keeps only 10% of failures. Tail sampling sees the finished trace and can keep every error while dropping most healthy traffic. Short retention keeps every trace's ingest cost and loses history you need for the postmortem.
Joining the signals: exemplars and trace ids in logs
During an incident you move between signals: the metric shows that something is wrong, a trace shows where, and logs show the exact error. Two features make each jump one click.
An exemplar is a trace id attached to a metric sample. When a histogram counts a 4.2-second checkout into its slowest bucket, it can keep that request's trace id next to the count. In the OpenMetrics text format it looks like this:
http_server_request_duration_seconds_bucket{route="/v1/orders",le="5.0"} 1873 # {trace_id="4bf92f3577b34da6a3ce929d0e0e4736"} 4.21 1760985000.120Grafana draws exemplars as dots on the latency panel, and clicking a dot opens that trace. One detail: with tail sampling, an exemplar may point at a trace that was dropped. Slow and failed requests are the ones you click, and your policies keep exactly those, so in practice the links work where they matter. In Prometheus, storing exemplars is a feature flag (version dependent), and SDK support differs by language.
The other jump goes from a trace to its logs. OpenTelemetry's pino instrumentation adds trace_id and span_id to every log line written while a span is active. Your log tool can then link a trace to all of its log lines, across every service, even when the request id from sd-31 was lost at a queue boundary.
Build it
Instrument Mangrove's checkout: the SDK with resource attributes, a manual span for placing an order, context across the receipt queue, and a Collector with tail sampling. It builds on the NestJS setup from Observability: logs, metrics and traces. The trace header parsing at the heart of propagation is an exercise you can run in the browser in Practice.
-
Start the SDK, sending traces and metrics to the Collector
Load this file before the app, with
node -r ./dist/tracing.js dist/main.js. Resource attributes come from environment variables, so the same build runs in staging and production with different values.TypeScript// src/tracing.ts: runs before any other module is loaded import { NodeSDK } from '@opentelemetry/sdk-node'; import { getNodeAutoInstrumentations } from '@opentelemetry/auto-instrumentations-node'; import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-http'; import { OTLPMetricExporter } from '@opentelemetry/exporter-metrics-otlp-http'; import { PeriodicExportingMetricReader } from '@opentelemetry/sdk-metrics'; const sdk = new NodeSDK({ traceExporter: new OTLPTraceExporter(), // reads OTEL_EXPORTER_OTLP_ENDPOINT metricReader: new PeriodicExportingMetricReader({ exporter: new OTLPMetricExporter(), exportIntervalMillis: 15000, // one push every 15 s }), instrumentations: [getNodeAutoInstrumentations()], // http, express, nestjs, pg, ioredis, pino }); sdk.start(); process.on('SIGTERM', () => sdk.shutdown().finally(() => process.exit(0)));Terminal# set per environment (Kubernetes manifest, compose file or CI secret) OTEL_SERVICE_NAME=order-service OTEL_RESOURCE_ATTRIBUTES=service.version=5.2.0,deployment.environment.name=production OTEL_EXPORTER_OTLP_ENDPOINT=http://otel-collector:4318 OTEL_TRACES_SAMPLER=parentbased_always_on # keep everything here; the Collector samplesWith tail sampling in the Collector, the SDK records every trace. If the span volume is too big even for the Collector, add a head ratio first and keep the tail policies on what remains.
-
Add a manual span with business context
Auto instrumentation already makes the HTTP and database spans. This span wraps them and adds what only checkout knows. The pg and HTTP spans created inside the callback become its children.
TypeScript// src/checkout/checkout.service.ts import { trace, SpanStatusCode } from '@opentelemetry/api'; const tracer = trace.getTracer('mangrove-checkout'); async placeOrder(cmd: PlaceOrderCmd): Promise<Order> { return tracer.startActiveSpan('checkout.place_order', async (span) => { try { span.setAttributes({ 'mangrove.store.id': cmd.storeId, // fine on a span, never a metric label 'mangrove.cart.items': cmd.lines.length, 'mangrove.payment.method': cmd.paymentMethod, // card | wallet | cod 'mangrove.app.version': cmd.appVersion, }); const order = await this.orders.reserveStock(cmd); span.addEvent('stock.reserved', { 'mangrove.order.id': order.id }); const payment = await this.payments.charge(order); // outgoing HTTP carries traceparent span.setAttribute('mangrove.payment.attempts', payment.attempts); return order; } catch (err) { span.recordException(err as Error); span.setStatus({ code: SpanStatusCode.ERROR, message: (err as Error).message }); throw err; } finally { span.end(); // a span that never ends is never exported } }); } -
Carry the context through the receipt queue
The producer copies the current context into the job. The worker reads it back and starts a new trace with a link, as in the first diagram. The queue here is BullMQ; any queue works the same way, because the context is only a few strings in the job data.
TypeScriptimport { context, propagation, trace, SpanKind } from '@opentelemetry/api'; // producer, in the order service after the order is saved const otel: Record<string, string> = {}; propagation.inject(context.active(), otel); // writes otel.traceparent await receiptQueue.add('send-receipt', { orderId: order.id, otel }); // consumer, in the worker process new Worker('receipts', async (job) => { const producerCtx = propagation.extract(context.active(), job.data.otel ?? {}); const producer = trace.getSpanContext(producerCtx); // undefined for old jobs await tracer.startActiveSpan( 'receipts process', { kind: SpanKind.CONSUMER, links: producer ? [{ context: producer }] : [] }, async (span) => { try { await sendReceipt(job.data.orderId); } finally { span.end(); } }, ); }, { connection }); -
Configure the Collector pipeline
The
tail_samplingandattributesprocessors ship in the contrib distribution of the Collector. Policies are combined with OR: a trace is kept if any policy says keep.YAML# otel-collector.yaml (second tier: receives all spans of a trace) receivers: otlp: protocols: grpc: { endpoint: "0.0.0.0:4317" } http: { endpoint: "0.0.0.0:4318" } processors: memory_limiter: # first: refuse data before running out of memory check_interval: 1s limit_percentage: 80 spike_limit_percentage: 20 attributes/scrub: actions: - { key: http.request.header.authorization, action: delete } - { key: mangrove.customer.id, action: hash } tail_sampling: decision_wait: 10s # wait this long after a trace's first span num_traces: 200000 # traces held in memory while waiting policies: - { name: errors, type: status_code, status_code: { status_codes: [ERROR] } } - { name: slow, type: latency, latency: { threshold_ms: 2000 } } - name: payment-retries type: numeric_attribute numeric_attribute: { key: mangrove.payment.attempts, min_value: 2, max_value: 100 } - { name: baseline, type: probabilistic, probabilistic: { sampling_percentage: 5 } } batch: {} # last: fewer, bigger exports exporters: otlp: endpoint: trace-store:4317 tls: { insecure: true } # inside the cluster only service: pipelines: traces: receivers: [otlp] processors: [memory_limiter, attributes/scrub, tail_sampling, batch] exporters: [otlp] -
Validate the config before you deploy it
Pin a Collector release and validate the file in CI, so a typo fails the pull request instead of the festival.
Terminaldocker run --rm -v "$PWD/otel-collector.yaml:/etc/otelcol-contrib/config.yaml" \ otel/opentelemetry-collector-contrib:0.120.0 \ validate --config=/etc/otelcol-contrib/config.yaml # exits non-zero on a bad configThen send one checkout through staging and look for three things: one trace from gateway to payment service, a linked receipt trace, and log lines that carry the same
trace_id.
Bring it to your team
Ask these in the design review for any service on the checkout path.
- "If checkout fails tomorrow only for wallet payments on one app version, which query shows that, and does it need a deploy?"
- "How many time series does this service create, and what is its budget?"
- "Where does trace context cross a queue, a cron job or a third party, and do we inject and extract it there?"
- "Which tail sampling policy keeps the traces we will want in the next incident?"
- "Which attributes could hold personal data, and which Collector rule scrubs them?"
- "Do service.name, service.version and the environment appear on every span and metric?"
Red flags
- Store ids, order ids or customer ids as metric labels
- Span names with ids in them, such as
GET /orders/88123 - Head sampling at 10% while the team expects to find every failed checkout
- Several tail-sampling Collectors behind a round-robin load balancer
- Personal data in baggage, or no scrubbing rule in the Collector
- A shared library that installs the SDK or an exporter itself
Practice
Parse a traceparent header
The receipt worker gets trace context from job data written by several services, and some old jobs carry broken values. Write parseTraceparent(header). Return { traceId, spanId, sampled } for a valid version 00 header, and null for anything else. Valid means: exactly four dash-separated parts; version 00; a 32-character trace id and a 16-character span id in lowercase hex, neither all zeros; 2 hex characters of flags. sampled is true when the lowest bit of the flags is set. The starter trusts every header.
function parseTraceparent(header) {
// return { traceId, spanId, sampled } or null when the header is not valid
const [version, traceId, spanId, flags] = header.split('-');
return { traceId, spanId, sampled: flags === '01' };
}
test('parses a valid sampled header', () => {
expect(parseTraceparent('00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01'))
.toEqual({ traceId: '4bf92f3577b34da6a3ce929d0e0e4736', spanId: '00f067aa0ba902b7', sampled: true });
});
test('sampled comes from the lowest bit of the flags (00 is false, 03 is true)', () => {
expect(parseTraceparent('00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-00').sampled).toBe(false);
expect(parseTraceparent('00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-03').sampled).toBe(true);
});
test('all-zero trace id or span id is invalid', () => {
expect(parseTraceparent('00-00000000000000000000000000000000-00f067aa0ba902b7-01')).toBe(null);
expect(parseTraceparent('00-4bf92f3577b34da6a3ce929d0e0e4736-0000000000000000-01')).toBe(null);
});
test('wrong version, length, case or characters is invalid', () => {
expect(parseTraceparent('ff-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01')).toBe(null);
expect(parseTraceparent('00-4bf92f3577b34da6a3ce929d0e0e47-00f067aa0ba902b7-01')).toBe(null);
expect(parseTraceparent('00-4BF92F3577B34DA6A3CE929D0E0E4736-00f067aa0ba902b7-01')).toBe(null);
expect(parseTraceparent('00-4bf92f3577b34da6a3ce929d0e0e47zz-00f067aa0ba902b7-01')).toBe(null);
expect(parseTraceparent('00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01-extra')).toBe(null);
});
test('a missing or non-string header is invalid', () => {
expect(parseTraceparent('')).toBe(null);
expect(parseTraceparent(undefined)).toBe(null);
});
Show a solution
function parseTraceparent(header) {
if (typeof header !== 'string') return null;
// version 00, 32 hex trace id, 16 hex span id, 2 hex flags, nothing else
const m = /^00-([0-9a-f]{32})-([0-9a-f]{16})-([0-9a-f]{2})$/.exec(header);
if (!m) return null;
const [, traceId, spanId, flags] = m;
if (/^0+$/.test(traceId) || /^0+$/.test(spanId)) return null; // all zeros is invalid
return { traceId, spanId, sampled: (parseInt(flags, 16) & 1) === 1 };
}Checking flags === '01' breaks as soon as a caller sets another flag bit, and newer versions of the standard define more bits. Masking with & 1 reads only the sampled bit. The real standard lets a parser accept higher versions with extra fields; this exercise accepts version 00 only, which is what OpenTelemetry sends today.
Decide where each field goes, and what to keep
The payment service team proposes this telemetry for the festival. For each field, say whether it should be a metric label, a span attribute, a resource attribute, or not recorded at all, and why. Then write the tail sampling policies (in words or YAML) for checkout traces, keeping the trace bill at roughly a tenth of keeping everything. Fields: payment_method (card, wallet, cod), store_id (320 values), order_id, customer phone number, service.version, number of payment attempts, the payment provider's raw error message.
- payment_method is the only metric label (3 values); store_id and order_id go on spans
- The phone number is not recorded, or is dropped or hashed by a Collector rule
- service.version is a resource attribute, set from the environment
- Attempt count is a span attribute, and a policy keeps every trace with 2 or more attempts
- Policies keep all errors and slow traces plus a small probabilistic share, run in the Collector
- You note that all spans of a trace must reach the same Collector
Show a model answer
## Payment telemetry for the festival
Metric labels (on the payment latency histogram)
- payment_method: 3 values, about 3x the series. Fits the 50,000 budget.
Span attributes (on the payment span)
- mangrove.store.id: 320 values would multiply series by 320; fine on spans
- mangrove.order.id: unique per order; spans and logs only
- mangrove.payment.attempts: a number; drives a sampling policy
- provider error message: as an event on the span, truncated to 200 chars
Resource attributes
- service.version: from OTEL_RESOURCE_ATTRIBUTES, set by the deploy
Not recorded
- customer phone number: personal data. Collector rule deletes it if
it ever appears (attributes processor, action delete).
Tail sampling (second-tier Collector, spans routed by trace id)
- keep: status ERROR
- keep: latency over 2,000 ms
- keep: mangrove.payment.attempts of 2 or more
- keep: 5% of everything else
Expected: roughly 7-10% of traces kept, and every trace we want in an incident.Check yourself
At 19:10 on festival night, place-order p99 latency doubles. The RED dashboard shows only route and status code. Imran asks whether it hits only wallet payments from app version 5.2. What answers this fastest without a deploy?
The spans already carry those attributes, so a query in the trace tool answers it now. New labels or debug logging need a deploy in the middle of the incident and only show data from that moment on. Per-pod p99 answers a different question.
Mangrove runs three Collectors with tail sampling behind a round-robin load balancer. Many kept error traces are missing the payment service's spans. What is the cause and fix?
Each Collector sees only part of each trace, so the one that sees the error keeps its part and the others drop theirs. Routing by trace id sends every span of a trace to one Collector. A 10-minute wait would hold far too many spans in memory, tail sampling does not run in the SDK, and batch does not drop spans.
A nightly job settles 500 cash-on-delivery orders in one run. Each order was placed in its own checkout trace. How should the settlement span relate to those orders?
A span has one parent, and the run happens hours after checkout, so a child relation would distort the order traces. Links connect one span to many span contexts in other traces, which is what they are for. Reusing a trace id mixes unrelated work into one customer's trace.
Flashcards
Key takeaways
- Observability means answering new questions with data you already send. Put high-cardinality context on spans and logs, and keep metric labels within a series budget.
- Libraries depend on the OpenTelemetry API; services install the SDK. Auto instrumentation gives the skeleton, manual spans and attributes add the business context.
- traceparent carries the trace across HTTP. Through queues you inject and extract it yourself and use links for asynchronous and batch work.
- A Collector pipeline of memory_limiter, scrubbing, tail sampling and batch keeps every error and slow trace for a fraction of the cost, as long as each trace reaches one Collector.
- Exemplars link metrics to traces, and trace_id in every log line links traces to logs.
Sources
- Trace Context, W3C Recommendation
- Context propagation, OpenTelemetry documentation
- Sampling, OpenTelemetry documentation
- Collector configuration, OpenTelemetry documentation