Engineering Atlas › System Design Fundamentals

Logs, metrics and traces together

15 min read · last reviewed 2026-10-17

Open in the interactive app: practice, quiz, mock interview →

In one minute

How it works

One event, three shapes

Take one checkout request that fails because the payment provider timed out after 2 seconds. There are three classic shapes for recording it.

A metric turns it into increments: http_server_requests_total{route="/checkout",status="502"} goes up by one, and the 2.1 second duration lands in the le="2.5" bucket of a latency histogram. The request ID, the customer and the error message are gone. What remains is tiny and fast to query over months.

A log line keeps what the code chose to say at one point in one process: {"level":"error","msg":"payment authorise failed","provider":"acme","timeout_ms":2000,"trace_id":"4bf9..."}. It is rich but local, blind to the 11 other services on the same request unless they share an ID.

A trace keeps the request's shape: a tree of timed spans. It shows that checkout waited on payments.authorise, which waited 2,000 ms on an outbound call, while inventory and pricing finished in 15 ms. It is sampled, so most requests have no trace at all. Google's Dapper sampled about one trace in 1,024 and found that still gave enough data for high-volume services, while sampling every request added 16 percent to average latency in their test (Dapper paper).

A database analogy: logs are the raw table, metrics are a materialised view with GROUP BY route, status already applied, and traces are a precomputed join across services on trace_id. This is the finding things fast problem shape: every observability backend is an index chosen in advance for the queries you expect at 3 a.m.

What each pillar is good at

Metrics are for detection and trend: the only signal cheap enough to cover every request, so alerts, SLOs and capacity plans live here (see the Metrics and Instrumentation and Alert Monitoring lessons). Google's four golden signals, latency, traffic, errors and saturation, are all metrics (Google SRE book). They cannot explain an outlier, because it was summed into a bucket.

Traces are for localisation: which hop grew, whether calls ran in sequence or in parallel, where retries stacked up. The Distributed Tracing lesson covers propagation and sampling.

Logs are for explanation: the exception, the SQL that timed out, the flag value, the config version. They are also all most third-party software emits, so they never go away. Amazon's guidance is one request log entry per unit of work, carrying the timings and dependency results for that request, which makes a log line behave like a small wide event (AWS Builders' Library).

The joins are the product

Three disconnected tools give you an engineer comparing timestamps by eye. The links that matter:

Beyond the pillars

Charity Majors and Ben Sigelman argue that the pillars framing confuses data types with capabilities. Their alternative is wide structured events: one record per request per service with dozens of fields (route, user tier, build ID, cache hit, flag values) in a columnar store that can group by any field at query time. Metrics become queries over events and traces become events with parent IDs. That answers high-cardinality questions ("only beta-build customers in ap-southeast-1 on Safari?") that pre-aggregated metrics cannot. The cost is more bytes per request than a counter, so most organisations keep metrics for alerting and long retention, and sampled traces plus structured logs (ideally one wide line per request) for investigation. Majors later named the split-silo approach "observability 1.0" and the single store of wide events "observability 2.0", arguing that 1.0 costs grow as a multiple of traffic because every signal stores the event again (charity.wtf).

The list of signals is also growing. OpenTelemetry's continuous profiling signal entered public alpha in 2026, and profile samples carry trace_id and span_id, so a slow span can open a flame graph of the code that ran during it (OpenTelemetry blog).

The same trade appears in a bank: account balances (aggregate), a transaction ledger (raw), and fraud case files that stitch transactions across accounts (the trace). The questions decide the shapes; the IDs that join them decide whether investigation takes minutes or days.

Down the stack

Layer 1The contract

None of the three signals promises completeness. Metrics promise monotonic counters per process; a missed scrape or a restart loses resolution, not totals, because rate() handles resets. Resolution is the scrape interval (10 to 60 seconds), so a 3 second spike is invisible in a gauge. Log agents such as Fluent Bit and Vector checkpoint file offsets and deliver at least once: duplicates after a crash, loss when the disk buffer overflows. A sampled trace is probably complete: SDK queues drop spans under pressure, and tail samplers decide on partial data if a span is late. The correlation contract is weakest: a log line has a trace ID only if it ran inside an active span, and an exemplar may point at a trace that was sampled out.

Layer 2The mechanism

OpenTelemetry defines one data model for all three. Each OTLP batch is grouped as ResourceSpans / ResourceMetrics / ResourceLogs, then by InstrumentationScope, then records. A span carries trace_id (16 bytes), span_id (8 bytes), parent_span_id, kind, start and end in Unix nanoseconds, attributes and status. A log record carries Timestamp, SeverityNumber (17 to 20 is ERROR), Body, attributes, optional TraceId and SpanId, and an optional EventName that marks the record as a named event (OTel logs data model). A histogram data point carries bounds, counts, sum and a list of Exemplar messages, each with a value, a timestamp and trace and span IDs. In OpenMetrics text, an exemplar follows a bucket line as # {trace_id="4bf9..."} 2.1 1760000000.123 (OpenMetrics spec). Prometheus keeps exemplars in a separate in-memory circular buffer rather than in TSDB chunks, and only when started with --enable-feature=exemplar-storage, which is still off by default (Prometheus feature flags). Exporters send OTLP on port 4317 (gRPC) or 4318 (HTTP), and the spec makes http/protobuf the default transport (OTLP exporter spec).

The Collector runs pipelines of receivers (OTLP, Prometheus scrape, filelog), processors (memory_limiter, batch, k8sattributes, tail_sampling) and exporters, with the spanmetrics connector turning spans into RED metrics. k8sattributes keeps resource attributes consistent by mapping the sender's pod IP to pod, namespace and deployment.

Layer 3The machine

Per-event cost differs by three orders of magnitude. A counter increment is an atomic add: 2 to 5 ns uncontended, 50 to 100 ns when 32 cores bounce the same cache line, which is why fast libraries shard counters per CPU. A histogram observation is about 20 to 50 ns. A JSON log line costs 1 to 5 microseconds to format plus a write(2) into the page cache, and if stdout is a pipe to a slow container runtime that write blocks the request thread. A span costs 0.5 to 2 microseconds and a few hundred bytes of allocation. On disk, a Prometheus sample compresses to 1 to 2 bytes (Prometheus storage); Facebook's Gorilla paper, which introduced the delta-of-delta and XOR scheme, measured 1.37 bytes against 16 raw (Gorilla, VLDB 2015); a JSON log line is 300 to 800 bytes before compression, and an OTLP span 400 to 1,000 bytes. One request costs almost nothing in metrics, a kilobyte or two of logs, and several kilobytes of trace if sampled.

Why it's built this way

  1. Storage and query cost scale with what you keep, so the cheapest signal must be pre-aggregated: metrics discard identity.
  2. Pre-aggregation hides outliers, so a second signal must keep individual events: logs and traces.
  3. Individual events across 20 services are useless without a join key, so trace context is propagated in headers and stamped on everything.
  4. Keeping every span is too expensive, so traces are sampled, which breaks some joins; exemplars and tail sampling exist to bias what is kept toward what someone will click.
  5. Three pipelines tripled the agents and formats, so OpenTelemetry unified the data model and the collector while leaving storage specialised.

Diagram

flowchart LR
  APP["Service + OTel SDK"] -->|"OTLP"| COL["Collector"]
  COL -->|"spanmetrics"| M["Metrics TSDB"]
  COL -->|"tail sample"| T["Trace store"]
  COL --> L["Log store"]
  A["Alert: p99 high"] --> M
  M -->|"exemplar trace_id"| T
  T -->|"trace_id filter"| L
  L -->|"build, flag, error"| FIX["Root cause"]

Worked example

A checkout flow runs at 5,000 requests per second. Each request touches 12 services (12 spans), and each service writes about 3 log lines per request.

Metrics: 40 pods times 1,500 series is 60,000 series. At one sample per 15 seconds and 1.3 bytes per sample, that is about 450 MB per day; thirteen months fits on one disk.

Logs: 5,000 times 12 times 3 lines times 500 bytes is 90 MB per second, 7.8 TB per day raw, about 1 TB compressed. Logs are the largest line item by far, and dropping debug lines plus the two chattiest info lines halves it.

Traces: 60,000 spans per second at 700 bytes is 42 MB per second unsampled. Tail sampling keeps all errors (0.4 percent of requests), all requests over 1 second (0.8 percent) and 1 percent of the rest: about 2.2 percent of traces, 80 GB per day.

The incident: the p99 alert fires at 1.9 seconds against an 800 ms SLO, only on /checkout, from 14:05. An exemplar in the 2.5 second bucket opens a trace where payments.authorise spends 1,850 ms in an outbound call with three retry spans. Filtering logs by that trace ID shows retry attempt=3 provider=acme status=503 and payments.timeout_ms=600 from a deploy at 14:04. Alert to cause: 6 minutes, against 50 minutes the previous quarter when the team grepped logs by timestamp across 40 pods.

In the wild

Trade-offs

OptionStrengthsWeaknessesChoose when
MetricsCheap, long retention, fast queries, ideal for alertingNo identity, cardinality limits, outliers averaged awayDetection, SLOs, capacity, trends
LogsRichest detail, works for any software, ad hoc text searchExpensive per byte, local to one process without IDsExplaining a specific failure, audit trails
TracesRequest shape across services, latency attributionSampling gaps, instrumentation effort, large spansLocalising latency and errors in distributed calls
Wide eventsArbitrary high-cardinality group-by after the factHigher per-request cost, needs a columnar backendDebugging unknown failure modes in complex systems
Separate vendor per pillarBest-of-breed toolsBroken joins, triple agents, triple billsRarely; only when migration cost dominates

Pitfalls in production

Leadership lens

Check yourself

  1. Why can a metric not tell you which customer had the slowest request, even if it is a histogram with fine buckets?

    Show answer

    Observations are summed into bucket counters at write time, so identity is discarded. Only an exemplar or a log or span with that customer's ID can point back to the request.

  2. Your p99 panel has exemplars, but clicking them often shows "trace not found". What is the most likely cause?

    Show answer

    The exemplar was recorded for a request whose trace was dropped by sampling. Record exemplars only for sampled spans, or bias sampling to keep the slow traces that exemplars land on.

  3. Why do span-derived metrics have to be computed before tail sampling rather than after?

    Show answer

    After sampling, the remaining spans are biased toward errors and slow requests, so rates and percentiles computed from them would overstate both.

  4. A team proposes logging every request at info level instead of adding tracing. What do they lose?

    Show answer

    Cross-service structure: parent-child timing, parallel versus sequential calls, and where time went. They also pay per byte for every request instead of sampling, and still need IDs to join lines across services.

Sources and further study

Primary

Systems docs

Books

Other curricula

Watch

Free resources

Open in the interactive app: practice, quiz, mock interview →