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.
🎯 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.
- 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
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.
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
- 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
- 1969syslog · UnixThe original 'log to a file' interface. Still used today.
- 1988SNMPSimple Network Management Protocol — the first standard 'give me numbers over time' interface. Predecessor of all metrics systems.
- 2010Google's Dapper paper'Dapper, a Large-Scale Distributed Systems Tracing Infrastructure'. The blueprint every trace system (Zipkin, Jaeger) copies.
- 2012PrometheusSoundCloud releases the pull-based, label-oriented metrics DB that becomes the CNCF default.
- 2016ELK stack peakElasticsearch + Logstash + Kibana is the log-analytics duct tape of an entire industry.
- 2019OpenTelemetryOpenTracing + OpenCensus merge. Single vendor-neutral SDK for all three pillars.
- 2022Observability 2.0 · wide eventsHoneycomb, 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
Time to serve a request. Track p50, p95, p99 separately from mean. Averages lie.
Requests per second (or transactions, or messages). Tells you scale.
Rate + ratio of failed responses. Split by HTTP status class.
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
Comparing the three pillars
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
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
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
"Logs, metrics and traces are three ways of looking at the same data. Collect all three and you have observability."
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.
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.
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.Why does high cardinality break metrics systems but not logging systems? Both are storing data about the same events.
- 1A 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
- 2Each 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
- 3The 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
- 4Therefore 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
- 5A 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 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.
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.
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.
How much telemetry do you keep, and at what fidelity?
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
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).
(d) Production reality · 15 min
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.
Where this shows up in the rest of the plan
(e) Recall + stretch · 10 min
Explain-out-loud test
Teach these three, no notes:
- The three pillars — one sentence, one question each answers best.
- The four golden signals — and why you monitor them.
- 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.