Infrastructure
Infrastructure

Observability basics: logs, metrics, traces and alerts

Full · ~45 min
After this unit you can
  • Say which question a log, a metric and a trace each answer, and write structured log lines that carry the request's trace ID
  • Choose counter, gauge or histogram for a measurement, follow Prometheus naming, and keep label cardinality bounded
  • Read a trace — spans, parent/child, kind, status — and explain how traceparent carries it across services
  • Explain why an average hides tail latency, and compute percentiles and an under-300 ms share from cumulative le buckets
  • Alert on symptoms users feel — golden signals and error-budget burn rate — rather than on causes like CPU

Read

Simple
1 / 2

Logs, metrics and traces answer different questions

Flag

Production is a black box until it tells you something. It can tell you three ways, and each answers a different question:

SignalOne data point is…AnswersCost grows with
Metrica number for a time window: 412 requests, 3 errors"Is it healthy? Is it getting worse?"the number of distinct label combinations
Logone event, with whatever detail you attach"What happened to this request or user?"traffic
Traceone request's path through every service, as timed spans"Where did the time go — which hop failed?"traffic (so it's usually sampled)

Dashboards and alerts are usually built on metrics, because they're cheap to keep for months. When one fires, you drill into traces to find where, and logs to find what exactly.

Structured logs: fields, not sentences

// Hard to search: the useful values are buried in prose
console.log(`order o_123 failed for user 42 after 2310ms: payment timeout`);

// Structured: one JSON object per line, values in named fields
console.log(JSON.stringify({
  level: "error", msg: "order failed", order_id: "o_123",
  duration_ms: 2310, error: "payment timeout",
  trace_id: "c50ed0daa0f6fd6a6087220a3d34fffa",
}));

The second line can be filtered (level = "error" and duration_ms > 2000), counted and joined — no regex over prose. Two habits make it pay off:

  • Same field names everywhere — order_id in every service, not orderId here and order there. OpenTelemetry's docs make the point that JSON alone isn't "structured" in a useful sense; a stable schema is.
  • Carry the trace ID. With trace_id on every line, one click goes from a slow trace to every log line that request wrote, across services. OpenTelemetry SDKs can add it for you when you route your logging library through them.

What not to log: passwords, tokens, full card numbers, and personal data you don't need — logs are copied, shipped and kept far more widely than your database.

Medium
1 / 2

Counter, gauge, histogram — and the label that costs a fortune

Flag

What a Prometheus scrape of a small API looks like, in its text format:

# TYPE http_server_requests_total counter
http_server_requests_total{method="GET",route="/orders/:id",status="200"} 10432
http_server_requests_total{method="GET",route="/orders/:id",status="500"} 17

# TYPE jobs_waiting gauge
jobs_waiting 42

# TYPE http_server_request_duration_seconds histogram
http_server_request_duration_seconds_bucket{le="0.1"} 9210
http_server_request_duration_seconds_bucket{le="0.3"} 10102
http_server_request_duration_seconds_bucket{le="1"} 10398
http_server_request_duration_seconds_bucket{le="+Inf"} 10449
http_server_request_duration_seconds_sum 1342.7
http_server_request_duration_seconds_count 10449

Counter — only goes up (or resets to zero when the process restarts). Requests served, errors, bytes sent. The raw value is meaningless; you query its rate — rate(http_server_requests_total[5m]) — and Prometheus adjusts for resets. Prometheus's naming guide gives accumulating counts a _total suffix. Never use a counter for something that can go down.

Gauge — a value right now that moves both ways: jobs waiting, memory in use, open connections. You read it as is, or take its min/max over a window.

Histogram — the shape of a distribution, for latency and sizes. It's a set of counters, one per bucket, and the buckets are cumulative: le="0.3" (less than or equal) counts every request that took 0.3 s or less, including the 9,210 already counted under le="0.1". le="+Inf" counts everything and equals _count. From these you get percentiles and "what share of requests finished within 300 ms" (Complex tier). Note the unit in the name: Prometheus's guide asks for base units — _seconds, _bytes — not milliseconds.

OpenTelemetry has the same three ideas under its own names — Counter, UpDownCounter and Gauge, and Histogram — plus asynchronous variants that are read once per export.

Cardinality: every label combination is its own series

Prometheus's docs: "every unique combination of key-value label pairs represents a new time series" — so don't use labels for high-cardinality dimensions such as user IDs or email addresses. Do the arithmetic on that histogram:

  • 40 routes × 5 methods × 6 status codes = 1,200 label combinations;
  • with the 14 bucket boundaries OpenTelemetry recommends for request duration, each combination is 17 series (15 buckets including +Inf, _sum, _count) = 20,400 series — fine;
  • add user_id with 50,000 users and it's about a billion.

The usual leak isn't a deliberate user_id; it's a raw URL. path="/orders/o_8f3k2" creates a series per order, where route="/orders/:id" creates one. OpenTelemetry's HTTP conventions require http.route to be low-cardinality for exactly this reason. Put per-request detail — user, order, raw path — on logs and spans, which are priced per event, not per combination.

Complex
1 / 2

Percentiles from buckets — what a histogram can and can't tell you

Flag

A service doesn't keep every latency to sort later. It increments bucket counters, and every question has to be answered from those counts. Five requests — 50, 150, 250, 900 and 4,000 ms — with buckets at 100, 300 and 1,000 ms:

le (ms)1003001000+Inf
cumulative count1345

"What share finished within 300 ms?" — exact, if a bucket ends there

Read it straight off: count(le=300) / count(+Inf) = 3 / 5 = 60%. Prometheus's histogram guide writes the same thing over a window:

sum(rate(http_request_duration_seconds_bucket{le="0.3"}[5m]))
  /
sum(rate(http_request_duration_seconds_count[5m]))

and warns that it strictly requires a bucket boundary at 0.3. Ask "within 250 ms?" and the counts can't say — the requests between 100 and 300 ms are one number. So put bucket boundaries at your thresholds: if the target is "99% under 300 ms", 300 ms must be a bound.

"What's the p50?" — an estimate

histogram_quantile() finds the bucket the rank falls in and assumes the observations inside it are spread evenly (linear interpolation). The p50 rank is 0.5 × 5 = 2.5, which lands in the 100–300 bucket, three-quarters of the way through its two observations: 100 + 0.75 × 200 = 250 ms. Here that happens to be exact; in general the error is bounded by the width of the bucket the quantile lands in — anywhere from 100 to 300 ms.

Two edge rules from the Prometheus docs matter:

  • The lowest bucket is assumed to start at 0.
  • A quantile that lands in the +Inf bucket returns the highest finite bound. The p90 rank, 4.5, lands there, so the answer is 1,000 ms — while the real slowest request took 4 s. If your top bound is below your tail, the histogram can't show the tail at all.

Never average percentiles

Each instance of a service can compute its own p99, but you can't combine them. Instance A serves 100 requests, 98 at 10 ms and two at 1 s; instance B serves 100 requests, all at 10 ms. Their p99s are 1,000 and 10 ms; the average, 505 ms, is a latency no request had, and the true p99 of all 200 requests is 10 ms. Prometheus's guide calls averaged quantiles "statistically nonsensical". That's why its summary type, which precomputes quantiles in each process, can't be aggregated across instances, while histogram buckets can: sum the bucket counters from every instance (sum by (le) (rate(...))), then take the quantile.

Quiz

Checkout's p99 jumped from 300 ms to 4 s ten minutes ago. What tells you which downstream call got slow?

Flag

Which log line is most useful when you're debugging one failed order at 3 a.m.?

Flag

Which metric type fits "jobs currently waiting in the queue"?

Flag

Which label on http_server_request_duration_seconds would make the number of time series grow without bound?

Flag

Service A calls service B over HTTP, but B's spans always start a new trace instead of joining A's. What's the most likely cause?

Flag

Two instances serve equal traffic. One reports p99 = 1,000 ms, the other p99 = 10 ms. What is the service's p99?

Flag

The objective is "99% of requests within 250 ms", but the histogram's bounds are 100, 300 and 1,000 ms. What can you compute exactly?

Flag

Which of these should page someone at 3 a.m.?

Flag

Why does the SRE workbook advise against paging on "error rate above the SLO's 0.1% for 10 minutes"?

Flag

Fill in the blank

Put the active span's trace ID on a structured log line (OpenTelemetry JS API).

Flag
const ctx = trace.getActiveSpan()?.spanContext();
logger.info({ msg: "order placed", order_id: orderId, trace_id: ctx?. });

Requests per second over the last 5 minutes, from a counter.

Flag
(http_server_requests_total[5m])

Share of requests that finished within 300 ms, from a classic histogram in seconds.

Flag
sum((http_request_duration_seconds_bucket{="0.3"}[5m]))
  /
sum(rate(http_request_duration_seconds_count[5m]))

Mark the span as failed — not just annotated — and make sure it's exported.

Flag
try {
  await chargeCard(order);
} catch (err) {
  span.recordException(err);
  span.({ code: SpanStatusCode., message: err.message });
  throw err;
} finally {
  span.();
}

The W3C Trace Context header that carries a trace into the next service.

Flag
: 00-c50ed0daa0f6fd6a6087220a3d34fffa-78ea985cd0d32eef-01

Predict the output

const latenciesMs = [5, 40, 300, 1200];
latenciesMs.sort();
console.log(latenciesMs);
Flag
function percentile(samples, p) {
  const sorted = [...samples].sort((a, b) => a - b);
  return sorted[Math.ceil((p / 100) * sorted.length) - 1];
}

const samples = [...Array(98).fill(10), 2000, 2000]; // ms
const mean = samples.reduce((a, b) => a + b, 0) / samples.length;
console.log(mean, percentile(samples, 95), percentile(samples, 99));
Flag
tracer.startActiveSpan("charge-card", (span) => {
  try {
    throw new Error("card declined");
  } catch (err) {
    span.recordException(err);
  } finally {
    span.end();
  }
});
// The exported span's status code: UNSET = 0, OK = 1, ERROR = 2
Flag
// Top-level code: no span is active yet.
const parent = tracer.startSpan("load-order");
const child = tracer.startSpan("query-db");
child.end();
parent.end();
Flag

Challenge

Fix the latency percentiles and histogram buckets

Flag

latency.ts powers a status page: a latency summary from raw samples, and Prometheus-style histogram buckets with the question every latency objective asks — "what share of requests finished within 300 ms?". It runs, prints plausible numbers, and is wrong in four ways that are each easy to ship:

  1. percentile sorts as strings. Array.prototype.sort() without a comparator compares values as strings, so [300, 5, 1200, 40] sorts to [1200, 300, 40, 5]. It also sorts the caller's array in place.
  2. The rank is off by one. Nearest rank is 1-based: with n samples, pN is the value at rank ceil(N / 100 × n), which lives at index rank − 1. Indexing at floor(N / 100 × n) returns the next value up whenever N / 100 × n is a whole number (p50 of 100 samples), and undefined for p100.
  3. The buckets aren't cumulative. A Prometheus bucket labelled le="300" counts every request that took 300 ms or less — including the ones already counted under le="100" — and the +Inf bucket counts all of them. Counting each sample once, in the first bucket it fits, draws a bar chart, not a histogram.
  4. fractionWithin answers questions the buckets can't. A threshold between two bounds can't be answered from counts: the bucket that spans it mixes requests on both sides. Prometheus's histogram guide says its "within 300 ms" query strictly requires a bucket boundary at 0.3 s. Return null instead of borrowing the next bucket.

Your task

Edit latency.ts so the tests pass:

  • percentile sorts numerically, on a copy — the caller's array keeps its order
  • percentile uses a 1-based nearest rank: p50 of 101…200 is 150, p99 is 199, p100 is 200, and p50 of 10, 20, 30, 40, 50 is 30
  • toBuckets is cumulative: each le counts every sample at or below it, and the Infinity bucket counts them all
  • fractionWithin answers only at an existing bucket bound and returns null otherwise
  • summarize reports the tail the mean hides: mean 49.8 ms but p99 2,000 ms for 98 × 10 ms + 2 × 2 s
  • A 99%-within-300-ms check computed from toBuckets output gives 0.995 for the sample day

Loading the in-browser editor…

Further reading