Search Tech Journey

Find topics, journeys and posts

6-month learning plan77 / 130
back to blog
systemsintermediate 55m read

S077 · The 3 Pillars — Metrics, Logs, Traces

The senses of a running system. What each pillar is good at, what it's terrible at, and why you need all three — not two, not one — to debug production.

⚙️SystemsM09 · Observability & SRE· Session 077 of 130 90 min

🎯 Explain which pillar answers which question, wire up all three in a demo service, and stop debugging blind.

Why this session exists

Every production bug reduces to the same question: what is my system actually doing right now? Without observability the answer is "I dunno, check the logs I guess" and you spend 4 hours grepping through 40 GB. With good observability it's "metrics show latency spiked at 14:03, traces show it's the payment service, logs show the specific error" — 4 minutes. This session isn't about tools; it's about the three types of telemetry every serious system emits and what each one is uniquely good at. Learn this and every future tool (Datadog, Grafana, Honeycomb, Dynatrace) is just a different UI on the same three ideas.

You will be able to
  • Define metrics, logs, and traces in one sentence each and give the class of question each answers best.
  • Choose which pillar to reach for first for a given production incident.
  • Instrument a Python service with all three using OpenTelemetry.
  • Explain the cost model of each pillar (storage, cardinality, retention) and why teams over-provision one and starve another.
  • Recognise the 'wide event' movement and why it argues one pillar can replace all three.

Prerequisites

  • S062 · REST & HTTP — you understand what a service call looks like.
  • S068 · Distributed basics — traces exist because calls span services.


(a) Intuition · 5 min

Instruments in a hospital
🌍 Real world

A hospital watches a patient with three tools. Vital-signs monitors — heart rate, blood pressure, oxygen — sampled every second, always on the wall. Cheap to record, catches emergencies, but tells you nothing about why. The patient chart — every medication, every test result, every doctor's note. Rich context, but you have to know which page to read. The camera in the operating room — records exactly what happened in one specific procedure, timeline of every action, but you only pull it when something went wrong.

💻 Code world

Metrics = vital-signs monitor: numeric samples over time (request rate, error rate, latency, CPU), cheap, always-on. Alerts you 'something's wrong'. Logs = the patient chart: textual events with context, verbose, higher cost. Tells you 'what happened'. Traces = the OR camera: request-scoped timelines of every hop and span across services. Tells you 'where the time went'.

You need all three. Any team using only one misses whole classes of bugs.

The three pillars, precisely

What each pillar is uniquely good at
  • Metrics — aggregated numbers over time. Best for: 'is anything wrong?' + dashboards + alerts + capacity planning. Bad for: 'why is user X's request slow?'
  • Logs — timestamped, structured events. Best for: 'what specifically happened at 14:03:22?' + audit + debugging. Bad for: 'what's the p99 latency?'
  • Traces — request-scoped hierarchies of spans across services. Best for: 'where in the call chain did the time / error come from?' + service dependency mapping. Bad for: cheap, always-on production monitoring (usually sampled).
  • The three overlap. You could technically get metrics from logs. You could get traces from metrics + logs. But each pillar is optimised for its question and the specialised tool is much cheaper and faster.

A history of observability

  1. 1969
    syslog · Unix
    The original 'log to a file' interface. Still used today.
  2. 1988
    SNMP
    Simple Network Management Protocol — the first standard 'give me numbers over time' interface. Predecessor of all metrics systems.
  3. 2010
    Google's Dapper paper
    'Dapper, a Large-Scale Distributed Systems Tracing Infrastructure'. The blueprint every trace system (Zipkin, Jaeger) copies.
  4. 2012
    Prometheus
    SoundCloud releases the pull-based, label-oriented metrics DB that becomes the CNCF default.
  5. 2016
    ELK stack peak
    Elasticsearch + Logstash + Kibana is the log-analytics duct tape of an entire industry.
  6. 2019
    OpenTelemetry
    OpenTracing + OpenCensus merge. Single vendor-neutral SDK for all three pillars.
  7. 2022
    Observability 2.0 · wide events
    Honeycomb, ClickHouse-based tools push 'log high-cardinality wide events, derive everything else'. Contested but influential.

(b) Visual walkthrough · 15 min

The three pillars, side by side

The debugging flow — which pillar answers which question

The four golden signals — what to monitor first

11
Latency

Time to serve a request. Track p50, p95, p99 separately from mean. Averages lie.

22
Traffic

Requests per second (or transactions, or messages). Tells you scale.

33
Errors

Rate + ratio of failed responses. Split by HTTP status class.

44
Saturation

How full is the system? CPU, memory, queue depth, connection pool utilisation. Predicts near-future failures.

The observability pipeline

From your code to a dashboard

Instrumentation library
OpenTelemetry SDK in your service. Emits metrics + logs + spans as your code runs.
L7
Local agent (collector)
OTel Collector, Fluentd, or Vector — batches, transforms, redacts PII, forwards.
L6
Transport
OTLP (protobuf/gRPC), Prometheus scrape, or log-shipping over HTTPS.
L5
Storage backends
Prometheus/Mimir for metrics; Loki/Elasticsearch/CloudWatch Logs for logs; Jaeger/Tempo for traces.
L4
Query layer
PromQL, LogQL, TraceQL, SQL. Different query languages per pillar — a long-standing pain point.
L3
Visualisation + alerting
Grafana, Datadog, Honeycomb, or a vendor-specific UI. Alertmanager, PagerDuty for routing.
L2

Comparing the three pillars

Metrics

Cheap, aggregated, low cardinality

  • Cost: pennies per series per month
  • Retention: months (aggregated) to years
  • Cardinality: keep under 100 labels per metric
  • Best question: 'is anything wrong?'
  • Tools: Prometheus, Mimir, CloudWatch Metrics, Datadog
Logs

Full detail, unstructured OR structured

  • Cost: dollars per GB ingested
  • Retention: days-to-weeks (raw), months (indexed)
  • Cardinality: unlimited, but expensive
  • Best question: 'what happened at time X?'
  • Tools: Loki, ELK, CloudWatch Logs, Splunk
Traces

Per-request timelines with parent/child spans

  • Cost: often sampled (1%-10%) to bound cost
  • Retention: days
  • Cardinality: high (unique trace_id per request)
  • Best question: 'where in the call chain did the time go?'
  • Tools: Jaeger, Tempo, X-Ray, Zipkin, Honeycomb

The mental model to hold


Common misconception
✗ What most people think

"Logs, metrics and traces are three ways of looking at the same data. Collect all three and you have observability."

✓ What is actually true

They answer structurally different questions and are not substitutes. Metrics tell you that something is wrong and are cheap at any cardinality-controlled scale. Traces tell you where in a distributed call path the time or error went. Logs tell you why, with the specific detail. Collecting all three without connecting them gives you three separate haystacks. Observability is the ability to ask a question you did not anticipate — which requires correlation between the three, not merely their presence.

Why the myth is so sticky

The myth is sticky because vendors sell them as a bundle and every architecture diagram shows three parallel pipelines, which implies interchangeability. It is also true that they overlap: you can count log lines to make a metric, or log enough to reconstruct a trace. Doing so is expensive and slow, and the substitution fails exactly during an incident when you need an answer in seconds rather than a query over terabytes.

Prove it to yourself

Test correlation, which is the property that actually matters:

Starting from a latency spike on a dashboard,
can you reach the logs of one slow request
in under 60 seconds?

metric alert -> exemplar or trace ID
            -> the trace showing the slow span
            -> logs for THAT request, via trace_id

If any arrow requires copying a timestamp into
another tool and guessing, you have three
monitoring systems, not observability.
From first principles
Start with the question

Why does high cardinality break metrics systems but not logging systems? Both are storing data about the same events.

  1. 1
    A metric system pre-aggregates: it stores one time series per unique combination of label values, updated continuously as events arrive.
    forced by · pre-aggregation is what makes querying fast — a dashboard reads a compact series rather than scanning raw events
  2. 2
    Each series carries fixed overhead: an index entry, in-memory state, and a data point per scrape interval, retained for the full retention period whether or not anything is ever queried.
    forced by · a series must be materialised and maintained continuously, since aggregation cannot be done retroactively over data that was never kept
  3. 3
    The number of series is the product of the cardinalities of all labels, so adding one high-cardinality label multiplies total series by that cardinality.
    forced by · every distinct combination of label values is by definition a separate series
  4. 4
    Therefore adding a user ID or a request ID as a label converts a handful of series into one per user or per request, and memory and index size grow proportionally — often catastrophically and suddenly.
    forced by · the cost is per unique combination and per retention period, not per event
  5. 5
    A logging system has no such multiplication because it stores events, not series: one event costs one record regardless of how many distinct field values exist across the corpus.
    forced by · logs aggregate at query time over raw events, so nothing needs to be materialised in advance
⇒ Therefore

Therefore the cardinality limit is a direct consequence of pre-aggregation, which is also the source of metrics' speed. The property that makes them fast is the property that makes them expensive at high cardinality — you cannot have one without the other.

And note what this predicts: the correct division of labour falls out automatically. Low-cardinality dimensions you always want to slice by — service, region, status code, endpoint — belong in metric labels. High-cardinality identifiers — user ID, request ID, order ID — belong in logs and trace attributes, where cost is per event. It also predicts why wide structured events with query-time aggregation have gained ground: they accept slower queries in exchange for removing the cardinality constraint entirely, which is a genuine and coherent trade rather than a marketing position.

Mental modelDetect, locate, explain

Three questions in a fixed order during every incident. Metrics detect: something is wrong, and here is which signal moved. Traces locate: the time is being spent in this service, on this call. Logs explain: here is the exception, the parameter, the specific reason.

The value is in the arrows between them. A trace ID present in your logs and an exemplar linking a metric to a trace are what turn three tools into one investigation.

  • Alert on symptoms, not causes. Alert on user-visible signals — latency, error rate, saturation — rather than on internal conditions like CPU usage. High CPU is not a problem if users are fine, and an alert on it will train the team to ignore alerts.
  • Propagate trace context everywhere, including through queues and background jobs. Context that stops at an async boundary means every asynchronous path is invisible, and asynchronous paths are where the hard bugs live.
  • Sample traces intelligently: head sampling is cheap and discards errors at the same rate as successes, while tail sampling keeps every error and slow request at the cost of buffering. Keeping 100% of errors and a small percentage of successes is almost always the right shape.
  • Log in structured form with consistent field names, and include the trace ID in every line. An unstructured log is a string you will grep during an incident; a structured log is data you can query, aggregate and join.
🔔 Fires when you see

Fire this model when you see: an alert nobody can act on · an incident where the first twenty minutes were spent finding which service was slow · logs without request identifiers · a dashboard showing averages · a trace that stops at a queue boundary.

The tradeoff

How much telemetry do you keep, and at what fidelity?

Keep everything at full fidelity
+ you gain any question can be answered after the fact, including questions about incidents you did not know were happening. No sampling means no argument about whether the missing data was the important data.
− you pay observability spend can become comparable to production infrastructure spend, and query performance degrades as volume grows. Teams then respond by reducing retention under budget pressure, usually without deciding what they are giving up.
pick when a short window — days rather than months — for high-volume signals, where most investigations actually occur
Aggressive sampling and short retention
+ you gain predictable, low cost and fast queries. Forces discipline about which signals genuinely matter rather than instrumenting everything by reflex.
− you pay the rare event is exactly what you need during an incident, and uniform sampling discards rare events at the same rate as common ones. You will eventually investigate something and find the relevant request was not kept.
pick when high-volume, low-value signals such as successful health checks and routine background requests
Tiered by signal value
+ you gain metrics retained for a long time because they are cheap and support capacity planning; traces tail-sampled to keep all errors and slow requests with a small fraction of successes; logs retained briefly at full fidelity then aggregated or archived. Cost is spent where it answers questions.
− you pay requires actively deciding and maintaining a policy per signal, which is ongoing work that nobody owns by default and which drifts as new services are added.
pick when any organisation where observability cost has become visible enough for someone to ask about it
What a senior engineer actually does

Tier deliberately rather than applying one retention policy to everything. Metrics are cheap and worth keeping for a long time — they are what you need for capacity planning and for comparing today with last quarter. Logs are expensive and mostly consulted within days of being written, so a short full-fidelity window plus archival covers nearly every real use.

For traces, tail sampling is the highest-leverage change available: keeping 100% of errors and slow requests plus a small percentage of successful ones gives you nearly all the investigative value at a fraction of the volume, because nobody investigates a fast successful request. The failure mode to avoid is uniform head sampling at a low rate, which throws away errors at exactly the same rate as everything else — so the one trace you need during an incident is statistically unlikely to exist.


(c) Hands-on · 25 min

Let's instrument a tiny Flask service with all three pillars using OpenTelemetry. You'll see the same request produce a metric, a log, and a trace — and the connection between them.

#!/usr/bin/env python3
"""three_pillars.py — instrument a Flask service with metrics, logs, traces.
 
Run:
  pip install flask opentelemetry-api opentelemetry-sdk \\
              opentelemetry-instrumentation-flask \\
              opentelemetry-exporter-otlp-proto-grpc \\
              prometheus-client
  python three_pillars.py
  curl http://localhost:5000/order/42
 
To view telemetry:
  - Metrics: http://localhost:5000/metrics (Prometheus format)
  - Logs: printed to stdout with trace_id correlation
  - Traces: printed to stdout (in real setup: OTLP -> Jaeger)
"""
from __future__ import annotations
 
import logging
import random
import time
import uuid
 
from flask import Flask, jsonify, request
 
# --- Metrics via prometheus_client ---
from prometheus_client import Counter, Histogram, generate_latest, CONTENT_TYPE_LATEST
 
REQUESTS = Counter(
    "http_requests_total",
    "Count of HTTP requests",
    ["method", "endpoint", "status"],
)
LATENCY = Histogram(
    "http_request_duration_seconds",
    "Latency of HTTP requests",
    ["endpoint"],
    buckets=(0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1.0, 2.5, 5.0),
)
 
# --- OpenTelemetry tracing (in-memory / console exporter) ---
from opentelemetry import trace
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import (
    ConsoleSpanExporter, SimpleSpanProcessor,
)
 
trace.set_tracer_provider(TracerProvider())
trace.get_tracer_provider().add_span_processor(
    SimpleSpanProcessor(ConsoleSpanExporter())
)
tracer = trace.get_tracer(__name__)
 
 
# --- Logging with trace-id correlation ---
class TraceContextFilter(logging.Filter):
    def filter(self, record: logging.LogRecord) -> bool:
        span = trace.get_current_span()
        ctx = span.get_span_context()
        record.trace_id = format(ctx.trace_id, "032x") if ctx.trace_id else "-"
        record.span_id = format(ctx.span_id, "016x") if ctx.span_id else "-"
        return True
 
 
handler = logging.StreamHandler()
handler.setFormatter(logging.Formatter(
    "%(asctime)s %(levelname)-5s [trace=%(trace_id)s span=%(span_id)s] "
    "%(name)s: %(message)s"
))
handler.addFilter(TraceContextFilter())
log = logging.getLogger("orders")
log.addHandler(handler)
log.setLevel(logging.INFO)
 
 
# --- Simulated backends ---
def db_lookup(order_id: int) -> dict:
    with tracer.start_as_current_span("db.lookup") as span:
        span.set_attribute("db.order_id", order_id)
        time.sleep(random.uniform(0.005, 0.05))
        if order_id == 500:
            span.set_attribute("db.status", "not_found")
            raise LookupError(f"order {order_id} not found")
        return {"order_id": order_id, "total": random.randint(10, 500)}
 
 
def payment_check(order: dict) -> bool:
    with tracer.start_as_current_span("payment.check") as span:
        span.set_attribute("payment.amount", order["total"])
        # Simulate 'slow days' — 10% of calls
        delay = 2.0 if random.random() < 0.1 else random.uniform(0.01, 0.1)
        time.sleep(delay)
        span.set_attribute("payment.slow", delay > 1.0)
        return True
 
 
# --- HTTP handlers ---
app = Flask(__name__)
 
 
@app.route("/order/<int:order_id>")
def get_order(order_id: int):
    endpoint = "/order/<id>"
    start = time.time()
    request_id = request.headers.get("X-Request-Id", str(uuid.uuid4())[:8])
    log.info("received order request order_id=%d req_id=%s", order_id, request_id)
 
    with tracer.start_as_current_span("handle_order") as span:
        span.set_attribute("http.request_id", request_id)
        span.set_attribute("order.id", order_id)
        try:
            order = db_lookup(order_id)
            payment_check(order)
            status = "200"
            log.info("order served order_id=%d total=%d", order_id, order["total"])
            response = jsonify(order)
        except LookupError as e:
            status = "404"
            log.warning("order not found order_id=%d err=%s", order_id, str(e))
            response = jsonify({"error": str(e)}), 404
        except Exception as e:  # noqa: BLE001
            status = "500"
            log.exception("order failed order_id=%d", order_id)
            span.record_exception(e)
            response = jsonify({"error": "internal"}), 500
 
    # Metrics update
    REQUESTS.labels(method="GET", endpoint=endpoint, status=status).inc()
    LATENCY.labels(endpoint=endpoint).observe(time.time() - start)
    return response
 
 
@app.route("/metrics")
def metrics():
    return generate_latest(), 200, {"Content-Type": CONTENT_TYPE_LATEST}
 
 
if __name__ == "__main__":
    print("→ visit http://127.0.0.1:5000/order/42")
    print("→ or a slow one: for i in {1..20}; do curl -s /order/$i > /dev/null; done")
    print("→ metrics: http://127.0.0.1:5000/metrics")
    app.run(host="127.0.0.1", port=5000, debug=False)

What each block does

Anatomy of the script

REQUESTS + LATENCY (Prometheus)
Counter (monotonic) + Histogram (bucketed distribution). Labels [method, endpoint, status] give you the classic 'errors by endpoint' breakdown.
metrics
TracerProvider + ConsoleSpanExporter
Real deployments swap ConsoleSpanExporter for an OTLP exporter pointing at Jaeger/Tempo. Same API, different backend.
traces
TraceContextFilter on logging
Attaches the current trace_id + span_id to every log line. This is the magic that lets you jump from a log line to the full trace in Grafana/Honeycomb.
correlation
start_as_current_span nesting
Each with-block is a span. Nested with-blocks build the parent/child tree that becomes the flame graph in Jaeger.
spans
span.set_attribute
The 'high-cardinality wide event' seed. Add order_id, user_id, feature_flag, region — anything you might want to filter on later.
attrs
endpoint label = '/order/<id>'
CRITICAL: use the ROUTE not the actual URL, or every unique order_id becomes a separate time series (cardinality explosion).
cardinality
/metrics endpoint
Prometheus scrapes this every 15-60s. This pull model is what makes Prometheus so operable — no push, no auth headaches.
scrape
Try itCause a cardinality explosion, then fix it

Modify the metrics update to:

REQUESTS.labels(method="GET", endpoint=request.path, status=status).inc()

Send 1000 requests. Count the number of lines in /metrics with curl -s localhost:5000/metrics | grep http_requests_total | wc -l. Congratulations, you have exploded your Prometheus bill. Now revert — the route template gives you exactly the labels you meant (one per endpoint, not one per request).

💡 Hint · Change the metric label from `endpoint='/order/<id>'` to `endpoint=request.path` (the actual URL). Send 1000 requests to distinct order_ids. Visit /metrics — you now have 1000 time series for one metric. In a real Prometheus this can crash the scraper. Fix: revert to the route template.

(d) Production reality · 15 min

War story HoneycombDebugging with events vs three pillars
🔥 What broke

A customer of Honeycomb (a wide-event observability tool) was debugging why a small percentage of API calls were slow. Classic metric dashboards showed p99 spike but couldn't drill down. Logs showed thousands of unrelated errors but no obvious pattern. Traces were 1% sampled — most of the slow requests weren't in the sample.

🧯 The fix
They queried the raw event store: 'group slow requests by user_id, feature_flag_state, region, deploy_version'. Instantly saw: 100% of slow requests came from users on a specific feature flag combined with a specific deploy version. Ten seconds of query. Would have taken days across the three-pillar tools.
🎓 Lesson to steal
The three pillars were designed when storage was expensive. Now that column-store databases (ClickHouse, Bigtable, Bigquery) can handle high-cardinality events cheaply, some teams argue for a single 'wide event' foundation from which metrics + traces are derived. Contentious — but worth watching.
Post-mortem
War story Google · Dapper paper· 2010The paper that invented distributed tracing
🔥 What broke
Google's engineers were debugging a search latency spike across dozens of internal services. Their metrics said 'something is slow', logs said 'X served Y with status 200', but no one could see the FULL journey of one request across the tree of dependent services. Debugging took days.
🧯 The fix
Built Dapper: attach a unique trace_id at the load balancer, propagate it via headers to every downstream call, sample 1/1000 traces to keep storage manageable. Every service reports its span (start, end, attributes, parent_span_id) to a central store. Reconstruction shows the flame graph. Reduced debug time from days to minutes for entire classes of latency bugs.
🎓 Lesson to steal
Distributed tracing is the ONLY tool that gives you 'where did the time go across services'. Without it you are debugging blind in any system with more than 2 services. Adopt OpenTelemetry from day one — retrofitting tracing into 50 services is a 6-month project.
Post-mortem
War story Common failure — the log-blindness incidentdocumented across dozens of engineering blogs (Slack, Shopify, etc.)
🔥 What broke
Team relies entirely on logs for observability. During a p95 latency spike they grep logs for slow requests — but the log volume is so high (100 GB/day) that useful queries time out. They spot-check individual logs and see nothing suspicious. Meanwhile the actual issue (a hot partition on the DB) is invisible in logs.
🧯 The fix
Add metrics for the four golden signals + per-DB-node latency. The hot partition immediately shows up as one node's latency 10× the others. Add traces so cross-service latency is visible. Reduce log volume to what's actually useful (drop DEBUG in prod, redact and drop chatty health checks).
🎓 Lesson to steal
Logs are terrible at 'is anything wrong?'. Metrics are the right tool for that. Any team relying only on logs for monitoring is one incident away from a Grafana project.

Where this shows up in the rest of the plan

Observability underpins every reliability topic
S078 · Prometheus / Grafana / OTel
Next session: the specific tools. This session is the vocabulary.
S079 · SLIs / SLOs / error budgets
SLIs are metrics. This session's foundation.
S080 · Incident response
Observability is what you reach for during an incident. Without it: guessing.
S076 · Multi-region
Per-region metrics + traces are how you decide to failover.
S089 · Rate limiting
You need saturation metrics to know when to rate limit.
S128 · System design
Every good interview answer includes 'here's what we'd monitor'. This session is that.

(e) Recall + stretch · 10 min

Quick recall · click to reveal
★ = stretch question

Explain-out-loud test

Teach these three, no notes:

  1. The three pillars — one sentence, one question each answers best.
  2. The four golden signals — and why you monitor them.
  3. Why 'grepping logs' is not observability — and what you'd add.

Hub: The 6-Month Learning Plan


Part of a 130-session evergreen learning series. Session structure: intuition → visual → hands-on → production war stories → recall. Duration: 90 minutes.