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.
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.
# 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.
| Logs | Metrics | Traces | |
|---|---|---|---|
| Question answered | What happened, with what context? | How much, how often, how slow (as a distribution)? | Where did this request spend time, and in what shape? |
| Unit | One event | One number per label set per interval | One tree of spans per request |
| Cardinality tolerance | Unbounded: any field, any value | Low: a few bounded labels (route, status, region) | High per trace; volume controlled by sampling |
| Cost driver | Volume: lines × bytes × retention | Series count: labels multiply | Sample rate × spans per request |
| Latency to answer | Seconds 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 rate | No; for diagnosis after the alert |
| Cannot tell you | The distribution; that 1% of users wait 4 s | Which request, which user, or why | Anything 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.
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' lines11 ...ctx.getStore(), ...fields,12 }) + '\n')13}14 15// middleware: one context per request; every log() inside inherits it16app.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 —
routeandstatus, neveruser_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
How data moves through it
One request or event, hop by hop.
- 1Client → Gateway: a trace id is minted (or read from
traceparent) and a request id logged; the access log line is emitted on completion. - 2Gateway → Service: headers carry the trace context; the service opens a child span and binds the request-scoped logger.
- 3Service → Database: the driver instrumentation records a span per query with duration and, sampled, the statement.
- 4Service → Collector: spans, structured logs and metric samples are batched and shipped asynchronously off the request path.
- 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
- 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.
- 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
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).