System designCore12 min

Observability: metrics, logs & traces

When something breaks in production, you can't attach a debugger to a thousand servers. You have only what the system recorded about itself: numbers over time, a line for each event, and the path each request took. Recorded well, they turn a vague complaint into the one line of evidence that explains it.

1 · The problem

An average can look healthy while 1 in 50 bookings stalls

A hotel-booking site, Friday evening. Support starts getting messages: the Book button spins for several seconds. The dashboard looks fine. CPU is at 45%, the error rate is 0.2% as always, and the average response time is about 250 ms, well under its 500 ms alert. Here is what was really happening to booking requests:

  • Slowest 1% (p99)
  • Average
01,0002,0003,0004,000020406080Minutes after 21:00Booking time (ms)

Modeled: most bookings take about 150 ms (log-normal, σ = 0.5, mean about 170 ms). From 21:30 a growing share, 2% by 21:50, take 4,340 ms. Average = 0.98 × 170 + 0.02 × 4,340 ≈ 253 ms. The p99 jumps to 4.3 seconds as soon as more than 1% of bookings are slow.

The payment provider had stopped answering from one of its endpoints. The payment service waited 2 seconds, retried, waited again, and succeeded on the third try. Nothing failed, so the error rate stayed flat; one booking in fifty took 4.3 seconds, which the average barely shows. Retries had turned an outage into slowness (Timeouts, retries, backoff & jitter).

Monitoring watches for problems you predicted: CPU too high, errors above 1%. Observability is the ability to answer questions you didn't predict, from the outside, using what the system records about itself. Both rest on three kinds of data, called signals:

Signal

What it is

What it answers

Cost

Metrics

Numbers counted or measured over time

Is something wrong? How much, since when?

Low: fixed per series, whatever the traffic

Logs

A timestamped record of one event

What exactly happened to this request?

Grows with traffic

Traces

One request's path across services, with timings

Where did the time go?

Grows with traffic, so usually sampled

2 · Metrics

Metrics are cheap because they throw the details away

A metric is a number recorded at regular intervals, such as every 15 seconds. It keeps no detail about individual requests, which is why it stays cheap at any traffic: a counter of a million bookings takes the same space as a counter of ten. Three types cover almost everything:

Type

What it records

Example on the booking site

Counter

A total that only goes up (or resets to zero on restart); you graph its rate

Bookings made, payment calls failed

Gauge

A value that goes up and down

Requests in progress, free disk space, queue length

Histogram

How many observations fell in each range (bucket), plus their sum

Booking time: how many under 100 ms, under 250 ms, under 1 s…

Histograms matter because percentiles can't be averaged. Averaging the p99s of 40 servers doesn't give the p99 of all 40, but bucket counts can be added up across servers and the p99 read off the total (Latency). Had the booking site kept a histogram, its p99 would have shown the problem at 21:40.

Which numbers to keep? Three short checklists:

  • RED, for every service: rate of requests, errors, and duration (as a histogram).
  • USE, for every resource such as a CPU, a disk or a connection pool: utilization, saturation (work waiting), and errors.
  • The four golden signals combine them: latency, traffic, errors and saturation.

Each metric can carry labels, such as endpoint="/book" or status="500", and every distinct combination of label values is a separate time series stored on its own. That count is the metric's cardinality:

  • 20 endpoints × 5 status classes × 100 servers = 10,000 series: fine.
  • Add a user_id label with 2 million users, and even if each user touched a single endpoint on a single server, that is 2 million more series, every one stored every 15 seconds.

Keep labels to small, fixed sets. Detail about individual users and requests belongs in logs and traces.

3 · Logs

Logs keep every detail, so they cost the most

A log is a timestamped record of one event: a booking accepted, a payment retried, an exception thrown. Written as free text, it is easy for a person to read and hard for a machine to search. A structured log writes the same event as named fields, so you can ask for every line where provider_endpoint is eu-2:

One structured log line from the payment service

json
{
  "time": "2026-10-02T21:43:07.412Z",
  "level": "WARN",
  "service": "payments",
  "message": "provider timeout, retrying",
  "attempt": 1,
  "timeout_ms": 2000,
  "provider_endpoint": "eu-2",
  "booking_id": "B-88213",
  "trace_id": "4bf92f3577b34da6a3ce929d0e0e4736"
}

The last field, trace_id, is what lets you find this line from a slow request later (see below).

Logs grow with traffic, and the bill grows with them. At 2,000 requests a second, five lines per request and 400 bytes per line:

Window

Volume

Per second

2,000 × 5 × 400 bytes = 4 MB

Per day

4 MB × 86,400 ≈ 346 GB

Kept for 30 days

About 10.4 TB, all of it indexed for search

So teams choose what to keep:

  • Levels. Mark each line DEBUG, INFO, WARN or ERROR, and switch DEBUG off in production unless you are chasing a bug.
  • Sample the routine, keep the unusual. One success line in a hundred is plenty; every error and every retry is worth keeping.
  • Never log secrets. Passwords, tokens and card numbers end up in a log store that many people can read and that lives for months. Personal data needs a short, stated retention.

4 · Traces

A trace shows where one request's time went

A booking passes through the web tier, the booking service, the availability service, the payment service and the database. Each one only knows its own part. A trace records the whole path. It is made of spans: one span per unit of work, each with a name, a start time, a duration and the ID of the span that called it (its parent). The first span, with no parent, is the root span. Here is the trace of one slow booking:

Whole booking (root span)
4,340 ms
Check availability
40 ms
Charge card, try 1
2,000 ms
Charge card, try 2
2,000 ms
Charge card, try 3
200 ms
Save booking
60 ms

The child spans run one after another: 40 + 2,000 + 2,000 + 200 + 60 = 4,300 ms, and the booking service's own work makes up the last 40 ms. Two payment attempts that each waited the full 2-second timeout are the whole story.

No metric would have said this so directly. The payment service's average call time was a little high; the trace shows that the slow bookings are exactly the ones that hit two timeouts in a row.

Tracing every request costs as much as logging it, so most systems sample:

  • Head sampling decides at the start of the request, say keep 1 in 100. It is simple and cheap, but it decides before anyone knows whether the request will be interesting.
  • Tail sampling waits until the trace is complete, then keeps every error, every slow request, and a small share of the rest. It needs a collector that holds all spans for a few seconds, which costs memory and effort.

Head sampling's blind spot is rare failures. A bug that hits 1 in 10,000 requests at 2,000 requests a second fails 0.2 times a second; at 1% sampling you keep one example every 500 seconds, about every 8 minutes. Tail sampling keeps all of them.

5 · Connecting them

One ID passed along with the request ties the signals together

How does the payment service know its spans belong to the same trace as the web tier's? The trace ID travels with the request. Each service reads it from the incoming call, records its own spans under it, and passes it on. This is context propagation. Over HTTP, the standard header is traceparent, for example 00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01: a version, a 32-character trace ID, the 16-character ID of the calling span, and flags (the final 01 means this trace is being sampled).

Every span and every log line along the way carries trace 4bf9…, so the backend can assemble them into one picture. Try 2 is left out to keep the figure short.

If one service in the chain drops the header, say a queue consumer or an old library, the trace breaks into two unrelated halves. Propagation has to work at every hop, including through message queues. With the ID in place, the Friday incident took five steps:

  1. An alert fires

    Bookings slower than 1 second are burning the latency objective's error budget about 20 times too fast (see the next section).

  2. Metrics narrow it

    The p99 is up only on /book, and only on the payment service's outgoing calls.

  3. A trace shows where

    One slow booking's trace: two charge attempts of exactly 2,000 ms each.

  4. Logs say why

    Searching the logs for that trace ID finds "provider timeout, retrying" with provider_endpoint eu-2. Every timeout that evening names eu-2.

  5. Fix and confirm

    Payments switch to another endpoint. The p99 falls back to about 500 ms, and the alert clears.

6 · Alerting

Page people for what users feel, not for every cause

A symptom is what users experience: bookings failing or slow. A cause is why: a full disk, a busy CPU, a slow provider. There are endless possible causes and only a few symptoms, so alerts that wake someone up should watch symptoms. Every page should be urgent, visible to users, and something a person can act on now.

What you see

Page someone?

Why

Bookings failing or slow fast enough to threaten the objective

Yes

Users are affected now

CPU at 90% on one server

No; dashboard or ticket

Users may not notice; the autoscaler may already be adding servers

One of three database replicas down

Ticket

Redundancy is doing its job; fix it during working hours

The symptom alert is best written against the service level objective (Availability & reliability). With an objective of 99.9% good bookings over 30 days, the error budget is 0.1% of bookings. The burn rate is how fast you spend it: an error rate of 0.1% is a burn rate of 1, which uses up exactly the budget in 30 days. A burn rate of 14.4 for one hour spends 14.4 × 1 ÷ 720 hours = 2% of the month's budget. A common starting point:

Burn rate

Sustained over

Budget spent

Action

14.4

1 hour (and still in the last 5 minutes)

2%

Page

6

6 hours (and still in the last 30 minutes)

5%

Page

1

3 days (and still in the last 6 hours)

10%

Ticket

The short second window makes the alert stop soon after the problem does, instead of an hour later. On Friday, 2% slow bookings against a 0.1% budget is a burn rate of 20: the first rule pages within minutes, while a threshold on the average would never have fired.

7 · In practice

Instrument once, at every boundary, before you need it

Most teams use OpenTelemetry, an open, vendor-neutral standard and set of libraries for producing traces, metrics and logs. Its libraries instrument common web frameworks, database drivers and HTTP clients automatically, propagate trace context, and send the data to whichever backend you choose. You add your own spans and attributes where the business logic needs them.

Question during an incident

Signal to reach for

Is something wrong, and since when?

Metrics: RED per service, against the objective

Which service or dependency?

Metrics split by endpoint and dependency, then traces

Where did this request's time go?

A trace

Why did this request fail?

Logs found by its trace ID

A checklist:

  • Record RED for every service and USE for every resource, with latency as a histogram, never an average.
  • Keep metric labels small and fixed; put user and request detail in logs and traces.
  • Write structured logs with the trace ID in every line, sample the routine, keep every error, and never log secrets.
  • Propagate trace context through every hop, including queues, and prefer tail sampling if you can afford it.
  • Alert on symptoms, with burn-rate alerts on your objectives, and send cause-level warnings to dashboards and tickets.
  • Instrument before the incident: the data you didn't record is the data you will need.

Check yourself

5 questions

1. Most bookings take 150 ms, but 2% take 4.3 seconds. Which number on the dashboard shows it most clearly?
2. A teammate adds a user_id label to the request counter so they can see each user's traffic. There are 2 million users. What is the problem?
3. Your site handles 2,000 requests a second, and a bug fails 1 in 10,000 of them. With head sampling at 1%, how often do you keep a trace of a failing request?
4. How does a span recorded by the payment service end up in the same trace as the web tier's span?
5. Your objective is 99.9% good requests over 30 days. For one hour, 1.44% of requests fail. How much of the month's error budget did that hour spend?

Takeaways

Remember this

  • Metrics say something is wrong, traces say where, and logs say why; metrics stay cheap at any traffic, while logs and traces grow with it.
  • Record latency as a histogram: an average can stay flat while 1 in 50 requests stalls.
  • Every distinct label combination is a separate time series, so keep metric labels small and put per-user detail in logs and traces.
  • A trace ID propagated through every hop, and written into every log line, ties the three signals together.
  • Page on symptoms users feel, with burn-rate alerts on your objectives; send causes to dashboards and tickets.