Observabilityobservabilitylogsmetricstracesstructured logging

Logs, Metrics and Traces

Three signals that answer three different questions — logs: what happened; metrics: how much and how often; traces: where did this request spend its time — each with its own cardinality and cost profile, and none of which can substitute for the other when the p99 doubles at 3 a.m.

▶ InteractiveInterview questionDebug it
Progress
What problem does this solve?

A distributed request touches five processes, and "the site is slow" is not a stack trace. Without the three signals you cannot tell which service, which dependency, which fraction of users, or whether it is new; with them, the question "why is checkout slow?" becomes a 10-minute investigation instead of a night.

Three questions, three signals

Logs answer *what happened*: a discrete event with a timestamp and context. Make them structured — JSON with level, service, request_id, user_id, order_id, duration_ms — not prose, so they can be filtered and aggregated. Include a correlation id (the trace id, or a request id the gateway minted) in every line, or you cannot join the gateway’s "received" to the order service’s "failed". Logs are the highest-cardinality signal: a line per event with any fields you like. That is their power and their bill; a service doing 5,000 requests per second writing three lines each is 1.3 billion lines a day.

Metrics answer *how much, how often*: numbers aggregated over time, cheap to store because they are pre-aggregated. The two standard sets: RED for request-serving services — Rate (requests/s), Errors (failures/s), Duration (a latency histogram) — and USE for resources — Utilisation, Saturation, Errors — for CPU, connection pools, queues, disks. Duration must be a histogram, not an average: an average of 80 ms hides that 1% of users wait 4 s. Report p50, p95, p99; alert on p99. The constraint is cardinality: every distinct label combination is a time series, so route and status are fine labels and user_id is not — a million users times ten routes is ten million series and a dead metrics database.

Traces answer *where did this request spend its time*: a tree of spans, each with a start, a duration, a service name and a parent, sharing one trace id. A trace is the only signal that shows *structure* — that the order service called the database 38 times in series, or that two calls that could run in parallel ran one after the other. They are sampled (1–10% of requests, plus 100% of errors) because a full trace per request costs more than the request.

One request, three signals, one trace id
traceparent×38trace_idp99spansRequestAPI GatewayOrder ServiceDatabaseLogs: what happenedMetrics: how muchTraces: where the time went
ClientGateway / LBServiceWorkerDatabaseCacheQueue / LogObject storageCDNExternal system
The same request in the three signals
# metric (aggregated over 1 min, no per-request detail)
http_request_duration_seconds{service="order",route="/orders/{id}",quantile="0.99"} 0.412

# log line (one event, full context, this request only)
{"ts":"2026-08-25T03:14:07.512Z","level":"warn","service":"order","trace_id":"4bf92f35","span_id":"00f067aa",
 "order_id":"o_81723","msg":"slow order fetch","duration_ms":180,"db_calls":38}

# trace (where the 235 ms went — and why)
API Gateway            20 ms  ─────
  User Service         35 ms       ──────
  Order Service       180 ms             ──────────────────────────────
    └ Database        140 ms                    ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─  (38 spans, sequential)

The three pillars, side by side

The matrix is the answer to "why not just log everything?" and to "why not just add more metrics?". Each signal has a question it answers well, a cardinality it tolerates, a cost curve, and a blind spot that only another signal fills. The metric tells you p99 is 412 ms; the log tells you which order and that it made 38 database calls; the trace shows those 38 calls happened one after another and that the caller could not have known — the N+1 in the challenge the-140ms-database-span.

What each signal can and cannot tell you
LogsMetricsTraces
Question answeredWhat happened, with what context?How much, how often, how slow (as a distribution)?Where did this request spend time, and in what shape?
UnitOne eventOne number per label set per intervalOne tree of spans per request
Cardinality toleranceUnbounded: any field, any valueLow: a few bounded labels (route, status, region)High per trace; volume controlled by sampling
Cost driverVolume: lines × bytes × retentionSeries count: labels multiplySample rate × spans per request
Latency to answerSeconds to minutes (search)Sub-second (pre-aggregated)Seconds (fetch one trace)
Good for alerting?Rarely (error patterns)Yes: SLO burn rate on p99 and error rateNo; for diagnosis after the alert
Cannot tell youThe distribution; that 1% of users wait 4 sWhich request, which user, or whyAnything about the requests not sampled

Alert on symptoms, not causes

The instinct is to alert on everything measurable: CPU over 80%, a queue over 1,000 messages, a pod restarting. Those are *causes*, and most of them do not hurt anyone — CPU at 85% with p99 at 120 ms is a healthy, well-utilised service. Paging on causes trains the on-call to ignore pages. Alert instead on symptoms the user feels, expressed as SLOs (Availability, SLOs and Error Budgets): "99.9% of checkout requests succeed within 500 ms over 30 days". Page when the error budget is burning fast — a burn rate that would exhaust the month’s 43 minutes of allowed unavailability in a few hours — and put the cause-level signals on a dashboard the responder opens *after* the page.

This is where the three signals compose into a workflow. The metric fires the page (p99 or error rate over the SLO threshold). The trace for a slow sampled request shows which hop absorbed the time. The logs for that trace id show the input that triggered it. A team that has only logs cannot page reliably; a team that has only metrics knows something is wrong and not where; a team that has only traces sees 1% of requests and has no history.

A request-scoped structured logger that carries the trace id everywhere
1import { AsyncLocalStorage } from 'node:async_hooks'
2import { trace } from '@opentelemetry/api'
3
4const ctx = new AsyncLocalStorage<{ requestId: string; userId?: string }>()
5
6export function log(level: 'info' | 'warn' | 'error', msg: string, fields: Record<string, unknown> = {}) {
7 const span = trace.getActiveSpan()?.spanContext()
8 process.stdout.write(JSON.stringify({
9 ts: new Date().toISOString(), level, service: 'order', msg,
10 trace_id: span?.traceId, span_id: span?.spanId, // joins this line to the trace and to other services' lines
11 ...ctx.getStore(), ...fields,
12 }) + '\n')
13}
14
15// middleware: one context per request; every log() inside inherits it
16app.use((req, _res, next) => ctx.run({ requestId: req.id, userId: req.user?.id }, next))

Key points

  • Logs: what happened (high cardinality, high volume). Metrics: how much and how often (low cardinality, cheap, alertable). Traces: where the time went (structure, sampled).
  • Latency is a histogram, never an average; report p50/p95/p99 and alert on p99.
  • Metric labels must be bounded — route and status, never user_id; every label combination is a time series.
  • Put a trace id or correlation id in every log line, or the signals cannot be joined across services.
  • Alert on symptoms as SLO burn rate; keep causes (CPU, queue depth, restarts) on the dashboard for after the page.

Logs, metrics and traces for the same incident

One incident, three pillars
Checkout p99 jumped from 300 ms to 2.4 s at 14:02. Logs, metrics and traces each see the same incident and each answer a different question.
Pillar
Minute
gateway @ 14:02
rate810 req/s · 14:00 820
errors0.4% · 14:00 0.1%
duration p992,400 ms · 14:00 310 ms
p99/min: 310 · 305 · 2400 · 2380 · 2410
order-service @ 14:02
rate138 req/s · 14:00 140
errors0.2% · 14:00 0%
duration p992,280 ms · 14:00 180 ms
p99/min: 180 · 185 · 2280 · 2260 · 2300
user-service @ 14:02
rate605 req/s · 14:00 610
errors0% · 14:00 0%
duration p9936 ms · 14:00 35 ms
p99/min: 35 · 34 · 36 · 35 · 34
order-db connection pool in use (gauge)100% · pool/min: 42 · 45 · 100 · 100 · 100
metrics answer: How much, how often, since when. Rate, errors and duration per service per minute; the pool gauge pinned at 100% from 14:02. Cannot tell you: Which request. A p99 of 2.4 s is a population statistic — it names no order, no query, no line of code.
Step 1 · Alert on the symptom metrics The SLO “checkout p99 < 500 ms” starts burning error budget at 14× the sustainable rate. The alert fires on the symptom users feel, not on any cause — nobody wrote an alert for “pool exhausted”.
1/5 · Alert on the symptom

How data moves through it

One request or event, hop by hop.

  1. 1Client → Gateway: a trace id is minted (or read from traceparent) and a request id logged; the access log line is emitted on completion.
  2. 2Gateway → Service: headers carry the trace context; the service opens a child span and binds the request-scoped logger.
  3. 3Service → Database: the driver instrumentation records a span per query with duration and, sampled, the statement.
  4. 4Service → Collector: spans, structured logs and metric samples are batched and shipped asynchronously off the request path.
  5. 5Collector → Backends: metrics to a time-series store, logs to an index, traces to a trace store, all joined by trace_id.

When to use — and when not

Use it when
  • Any service in production: RED metrics and structured logs with a correlation id are the minimum, from the first deploy.
  • Traces the moment a request crosses a second process; before that, a single-process profile is cheaper.
  • SLO-based alerting once there is a user-facing latency or error target to defend.
Avoid it when
  • High-cardinality labels on metrics (user id, order id, raw URL with ids) — that data belongs in logs and traces.
  • Logging full request and response bodies at info level in production; it is the fastest way to a six-figure logging bill and a data-protection incident.
  • Paging on cause-level thresholds (CPU > 80%) for services whose user-facing SLO is fine.

Tradeoffs

Complexity
low → high
Ops cost
low → high
Latency
low → high
Consistency
weak → strong
Scalability
poor → strong

Instrumentation is cheap at runtime (microseconds per span, async log writes). The cost is storage and ingestion: logs by volume, metrics by series count, traces by sample rate.

How it fails

  • Unbounded metric labels (user_id) create millions of series; the metrics database falls over and takes the alerting with it.
  • Prose logs with no request id; an incident across four services is reconstructed by matching timestamps by hand.
  • Averages on dashboards; the mean is 80 ms while 1% of users wait 4 s and nobody sees it.
  • Alerting on causes; the on-call gets 40 CPU pages a week, mutes the channel, and misses the real outage.
  • Log volume outgrows the budget, retention is cut to 24 hours, and the incident from the weekend has no evidence by Monday.

How it scales

  • Logs scale by volume: sample debug lines, log once per request rather than per step, and tier retention (hot 7 days, cold 90).
  • Metrics scale by series count, not traffic; pre-aggregate at the source (histogram buckets) and keep labels bounded.
  • Traces scale by sample rate: head-sample 1–10%, tail-sample to keep 100% of errors and slow requests (Distributed Tracing).
  • The collector tier (an OpenTelemetry collector per node or a central pool) buffers and batches, so an observability backend outage never blocks the services.

How it interacts with databases, queues, caches, APIs and external systems

  • Database: query spans and pool-saturation (USE) metrics; slow-query logs cross-referenced by trace id (Reading EXPLAIN ANALYZE for what to do next).
  • Queue: consumer lag as the key metric (Kafka-Style Logs: Topics, Partitions, Offsets); trace context carried in message headers so a consumer’s span links to the producer’s.
  • Cache: hit ratio and latency metrics; a cache miss storm shows as a database-span surge in traces (Caching Architecture).
  • API gateway: the origin of the trace and of RED metrics for the whole edge (API Gateway).
  • External APIs: client spans with the vendor as the peer; their p99 is the number that decides your timeouts (Circuit Breaker).