1. Courses
  2. Engineering Roles
  3. Site Reliability Engineering
  4. Stop 2

40 minAdvanced

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 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 , 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 : 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?

API, SDK and instrumentation

OpenTelemetry splits into two layers. The (@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 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.

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. describe the whole operation: mangrove.payment.method = "wallet". 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. 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. 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 . Over HTTP it uses the W3C header, which has four parts separated by dashes:

Output
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 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.

Mangrove app POST /orders trace 4bf9: one request API gateway traceparent Order service same trace id Payment service job data: traceparent Job queue picked up later trace 7c1e: worker Receipt worker link new trace, linked to the order span
Over HTTP the traceparent header keeps one trace id from the gateway to the payment service. The queue job carries the context in its data; the worker starts its own trace and links back to the order span, so neither trace stretches over minutes.

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?

The Collector pipeline, sampling and cost

Services can send telemetry straight to a backend, but most teams put an in between. It speaks , 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_limiter goes 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.
  • attributes deletes 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_sampling decides which traces to keep, once they are complete.
  • batch goes near the end and groups spans into fewer, bigger sends.

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. 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.

order svc payment svc worker OTLP spans receiver: otlp memory_limiter refuses data near limit attributes/scrub hashes or drops PII tail_sampling waits 10 s, then keeps errors, slow, 5% of rest batch fewer, bigger sends exporter: otlp Collector (traces) kept traces trace store
One traces pipeline in the Collector. Processors run top to bottom: memory_limiter protects the Collector, the attributes processor scrubs personal data for every service at once, tail sampling keeps the traces worth keeping, and batch makes the export efficient.

Quick check

Ken wants every failed checkout trace kept during the festival, but the trace bill cut by about 90%. Which setup does that?

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 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:

Output
http_server_request_duration_seconds_bucket{route="/v1/orders",le="5.0"} 1873 # {trace_id="4bf92f3577b34da6a3ce929d0e0e4736"} 4.21 1760985000.120

Grafana 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.

  1. 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 samples

    With 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.

  2. 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
        }
      });
    }
  3. 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.

    TypeScript
    import { 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 });
  4. Configure the Collector pipeline

    The tail_sampling and attributes processors 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]
  5. 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.

    Terminal
    docker 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 config

    Then 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.

JavaScript exerciseRuns in your browser with tests
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' };
}
Ctrl + Enter runs
Show a solution
JavaScript
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.

Pass the exercise to finish this task.

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
Markdown
## 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?

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?

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?

Flashcards

Open my review

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

Episode transcriptFinding the retry storm in minutes · about 12 minutes · tap any line to play from there
  1. NadiaImran, picture festival night, ten past seven in the evening. Checkout latency doubles. Your dashboard shows it per route and per status code. What's the first question you ask?
  2. ImranIs it everyone, or only some customers? Only wallet payments, maybe, or only the new app version.
  3. NadiaGood. And can the dashboard answer that?
  4. ImranNo. It only has route and status code. So I'd ask an engineer to add a payment method label and deploy.
  5. NadiaThat's the trap. A deploy in the middle of an incident is slow and risky, and it only shows data from that moment on. Today's episode is about having the answer already sitting there when the question arrives.
  6. ImranThis sounds like last year's festival. Checkout was down for forty minutes.
  7. NadiaIt's exactly that. We'll come back to it. First, a picture.
  8. NadiaThink of a parcel company. Every parcel gets a label with a tracking number. At each depot, someone scans it and adds a line: the time, the place, and a note like box damaged, or held for customs.
  9. ImranAnd the label travels with the parcel, so all those lines end up on one history page.
  10. NadiaRight. The depot also has a whiteboard that counts parcels per hour and how many are late. It's cheap and it tells you something is wrong. But it can't tell you why one parcel to one street was late.
  11. ImranFor that you open the tracking history.
  12. NadiaYes. In OpenTelemetry, the tracking number is the trace ID. Each scan line is a span, with attributes and events. The whiteboard is your metrics.
  13. ImranSo why not put everything on the whiteboard? Add a column for every store.
  14. NadiaThat's the most common mistake in this whole topic. In a metrics system, every new combination of label values is a new time series, and you pay for each one, all the time.
  15. NadiaHere's the lesson's count. The order service's latency histogram has about forty route and method pairs, about six status codes, and twelve series per histogram. That's two thousand eight hundred and eighty series per pod, and about thirty-five thousand across twelve pods.
  16. ImranAnd if I add store ID? We have three hundred and twenty stores.
  17. NadiaMultiply by three hundred and twenty. About eleven million series. That can take a Prometheus server down.
  18. ImranBut on a span, store ID is just one more field on one record.
  19. NadiaExactly. High-cardinality fields, the ones with many possible values like store ID, order ID or app version, go on spans and log lines. Metric labels stay small. Teams agree a cardinality budget, say fifty thousand series per service, and check it in code review.
  20. ImranOkay. Now, OpenTelemetry itself. People talk about the API and the SDK. Why two things?
  21. NadiaThe API is what code calls: start a span, set an attribute. On its own it does nothing; every call is a cheap no-op. The SDK is what the application installs at startup. It samples, batches and sends the data.
  22. ImranSo our shared payment client library should only depend on the API.
  23. NadiaYes. Then the service that uses the library decides where the data goes. The library never forces an exporter on anyone.
  24. ImranAnd instrumentation? I thought it was automatic now.
  25. NadiaHalf of it is. Auto instrumentation patches the common Node libraries at startup, so you get spans for every request, every database query and every outgoing call. But it doesn't know your business. It can't tell you this was a cash on delivery order with forty-three items and two payment attempts.
  26. ImranSo you add your own span around placing an order, with those details as attributes.
  27. NadiaRight, and events for moments inside it, like stock reserved. One rule: keep span names short and fixed. Checkout place order is a good name. A name with an order ID in it is a bad one.
  28. ImranBecause then every span name is unique and you can't group them.
  29. NadiaExactly. Also set resource attributes: the service name, its version, and the environment. They ride on every span and metric. Without them, staging traffic leaks into production dashboards.
  30. ImranNow, how does one trace cross several services? The gateway, the order service, the payment service.
  31. NadiaIf you can see your screen, the first diagram shows it top to bottom. If not, picture the app at the top, then the gateway, then the order service, which calls the payment service. One header travels along every call. It's called traceparent.
  32. NadiaIt has four parts separated by dashes: a version, a thirty-two character trace ID, a sixteen character ID of the calling span, and two characters of flags. The lowest bit of the flags means sampled.
  33. ImranAnd an ID of all zeros?
  34. NadiaInvalid. That's part of the practice exercise: you write a function that parses this header and rejects broken ones.
  35. ImranWhat about the queue? After an order, we put a send receipt job on the queue.
  36. NadiaThat's where traces usually break. HTTP is handled for you. A queue usually isn't. You copy the trace context into the job data yourself, and the worker reads it back.
  37. ImranAnd then the worker's span becomes a child of the order span?
  38. NadiaThat's the reasonable guess, and often the wrong one. The job may run minutes later, and a nightly batch may handle five hundred orders at once. A child would stretch the checkout trace over hours, or need five hundred parents.
  39. NadiaSo the worker starts its own trace and adds a span link back to the order span. In the diagram, that's the dashed line from the receipt worker up to the order service.
  40. ImranSo the trace tool can jump between the two, and each trace keeps a sensible length.
  41. NadiaYes. One warning while we're on headers. There's also baggage, a header for your own key value pairs. It goes out on every call, including to the payment provider. Never put a customer ID or phone number in it.
  42. ImranWhere does all this data go? Straight to the trace store?
  43. NadiaMost teams put a Collector in between. The second diagram shows its pipeline, top to bottom. Data comes in through a receiver, passes a chain of processors, and leaves through an exporter.
  44. NadiaThe processors run in order. The memory limiter is first, so the Collector refuses data before it runs out of memory, instead of crashing. Then an attributes processor hashes or drops personal data, for every service at once.
  45. ImranThen sampling?
  46. NadiaThen tail sampling, and finally batch, which groups spans into fewer, bigger sends.
  47. ImranI know sampling from the basics lesson. Keep ten percent, decided at the start.
  48. NadiaThat's head sampling. It's cheap, but blind. At the first span, nobody knows whether this checkout will fail. Keep ten percent at the head and you throw away ninety percent of your failed checkouts too.
  49. ImranWhich are exactly the ones I want during an incident.
  50. NadiaRight. Tail sampling waits until the trace is complete, about ten seconds in our setup, and then decides. Keep every error, every trace slower than two seconds, every trace with more than one payment attempt, and five percent of the rest.
  51. ImranWhat does that save?
  52. NadiaSay roughly ten thousand spans a second at the festival peak. That's about thirty-six million an hour. With those policies you keep roughly seven percent, around two and a half million an hour, and that includes every trace you'll want.
  53. ImranIs there a catch?
  54. NadiaOne big one. All spans of a trace must reach the same Collector. Put three Collectors behind a round-robin load balancer, and each sees part of each trace. Your error traces arrive with the payment spans missing.
  55. ImranSo you route by trace ID.
  56. NadiaYes. A first tier of Collectors uses the load balancing exporter, routing by trace ID, and the second tier does the tail sampling.
  57. ImranYou said we'd come back to last year.
  58. NadiaWe did a replay. Last year, the dashboards showed the order API's error rate climbing and the database looking fine. I spent the first twenty minutes on theories: a bad deploy, the database, Redis.
  59. NadiaThe real cause was a retry storm. The payment provider slowed down, our client retried failed calls at once without backoff, and the payment service ran out of pooled connections while requests waited.
  60. ImranAnd with the new telemetry?
  61. NadiaEvery payment call is now a span with the provider and the attempt count. Tail sampling keeps every trace with more than one attempt. In the replay, grouping the place order spans by attempt count showed the pattern within minutes.
  62. ImranAnd none of that went on metric labels, so the series budget held.
  63. NadiaRight. Two more links make moving between signals fast. An exemplar is a trace ID stored next to a metric sample, so you can click a dot on the latency panel and open a real slow trace. And the logging instrumentation adds the trace ID to every log line.
  64. ImranSo what does the Build it section actually do?
  65. NadiaFive steps. You start the SDK with resource attributes from environment variables, sending traces and metrics to the Collector. You wrap placing an order in a manual span with store, cart size, payment method and attempts.
  66. NadiaThen you inject the trace context into the receipt job and extract it in the worker, with a link. You write the Collector config with the four processors and the tail sampling policies. And you validate that config in CI, so a typo fails a pull request instead of the festival.
  67. ImranFor my next design review, here's what I'll ask. If checkout fails only for wallet payments on one app version, which query shows it without a deploy?
  68. NadiaGood one.
  69. ImranHow many time series does this service create, and what's its budget? And where does trace context cross a queue or a third party, and do we carry it there?
  70. NadiaAdd one: which attributes could hold personal data, and which Collector rule scrubs them?
  71. NadiaNow a question for you. A request arrives at the payment service with the sampled flag off. The payment service samples twenty percent of new traces, following its parent. What happens to its spans? Take a second.
  72. ImranIt follows the parent, so it doesn't record them. The twenty percent only applies when there's no parent.
  73. NadiaExactly. That's how a trace never gets recorded in one service and dropped in the next.
  74. ImranLet me sum up. One: put high-cardinality context like store ID on spans and logs, and keep metric labels inside a budget.
  75. ImranTwo: libraries use the API, services install the SDK, and you add manual spans for business context. Three: tail sampling in the Collector keeps every failed checkout for a fraction of the cost.
  76. NadiaOne nuance on three: it only works if every span of a trace reaches the same Collector. And for queues, carry the context yourself and use links.
  77. ImranNext time?
  78. NadiaNext episode, we turn this data into alerts that wake people only when the error budget is really burning.
Finished reading?Mark it complete to fill in its stop on your course line.

Use it at work

Runbook

Symptoms, first checks with commands, fixes and when to escalate, for one alert.

Open in the DevOps & TechOps Lab
Next stopAlerting that does not burn people out
Was this lesson helpful?
Questions and discussion

Loading the discussion…