Observability basics: logs, metrics, traces and alerts
- 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
traceparentcarries it across services - Explain why an average hides tail latency, and compute percentiles and an under-300 ms share from cumulative
lebuckets - Alert on symptoms users feel — golden signals and error-budget burn rate — rather than on causes like CPU
Read
Logs, metrics and traces answer different questions
FlagProduction is a black box until it tells you something. It can tell you three ways, and each answers a different question:
| Signal | One data point is… | Answers | Cost grows with |
|---|---|---|---|
| Metric | a number for a time window: 412 requests, 3 errors | "Is it healthy? Is it getting worse?" | the number of distinct label combinations |
| Log | one event, with whatever detail you attach | "What happened to this request or user?" | traffic |
| Trace | one 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_idin every service, notorderIdhere andorderthere. 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_idon 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.
Counter, gauge, histogram — and the label that costs a fortune
FlagWhat 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_idwith 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.
Percentiles from buckets — what a histogram can and can't tell you
FlagA 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) | 100 | 300 | 1000 | +Inf |
|---|---|---|---|---|
| cumulative count | 1 | 3 | 4 | 5 |
"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
+Infbucket 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?
FlagWhich log line is most useful when you're debugging one failed order at 3 a.m.?
FlagWhich metric type fits "jobs currently waiting in the queue"?
FlagWhich label on http_server_request_duration_seconds would make the number of time series grow without bound?
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?
FlagTwo instances serve equal traffic. One reports p99 = 1,000 ms, the other p99 = 10 ms. What is the service's p99?
FlagThe 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?
FlagWhich of these should page someone at 3 a.m.?
FlagWhy does the SRE workbook advise against paging on "error rate above the SLO's 0.1% for 10 minutes"?
FlagFill in the blank
Put the active span's trace ID on a structured log line (OpenTelemetry JS API).
Flagconst 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.
Flagsum((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.
Flagtry { 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-01Predict the output
const latenciesMs = [5, 40, 300, 1200]; latenciesMs.sort(); console.log(latenciesMs);
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));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// Top-level code: no span is active yet.
const parent = tracer.startSpan("load-order");
const child = tracer.startSpan("query-db");
child.end();
parent.end();Challenge
Fix the latency percentiles and histogram buckets
Flaglatency.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:
percentilesorts 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.- The rank is off by one. Nearest rank is 1-based: with
nsamples,pNis the value at rankceil(N / 100 × n), which lives at indexrank − 1. Indexing atfloor(N / 100 × n)returns the next value up wheneverN / 100 × nis a whole number (p50 of 100 samples), andundefinedfor p100. - 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 underle="100"— and the+Infbucket counts all of them. Counting each sample once, in the first bucket it fits, draws a bar chart, not a histogram. fractionWithinanswers 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. Returnnullinstead of borrowing the next bucket.
Your task
Edit latency.ts so the tests pass:
percentilesorts numerically, on a copy — the caller's array keeps its orderpercentileuses 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 30toBucketsis cumulative: eachlecounts every sample at or below it, and theInfinitybucket counts them allfractionWithinanswers only at an existing bucket bound and returnsnullotherwisesummarizereports 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
toBucketsoutput gives 0.995 for the sample day
Further reading
- OpenTelemetry: Traces
- OpenTelemetry: Metrics
- OpenTelemetry: Logs
- OpenTelemetry: Context propagation
- OpenTelemetry: Instrumentation for JavaScript
- OpenTelemetry semantic conventions: HTTP metrics
- Prometheus: Metric types
- Prometheus: Metric and label naming
- Prometheus: Histograms and summaries
- Prometheus: Query functions (histogram_quantile, rate)
- Google SRE book: Monitoring Distributed Systems
- Google SRE workbook: Alerting on SLOs
- Grafana docs: Dashboard best practices (RED, USE)