Instrumenting code to emit metrics
Open in the interactive app: practice, quiz, mock interview →In one minute
- A metric is a numeric time series identified by a name and a set of labels, sampled or aggregated at regular intervals. Metrics are cheap to store and fast to query because they are pre-aggregated.
- Three primitive types cover most needs: counters (only go up, such as requests served), gauges (go up and down, such as queue depth), and histograms (bucketed distributions, such as latency).
- Cost is driven by cardinality: the number of unique label combinations. One careless label can multiply series count by a million.
- Instrument with a method: RED (rate, errors, duration) for request-driven services and USE (utilization, saturation, errors) for resources. Google's four golden signals are RED plus saturation (Grafana, RED method).
- Percentiles cannot be averaged. Aggregate histograms, then compute quantiles.
How it works
The data model
A series looks like http_requests_total{service="checkout", method="POST", status="500"}. Each series is a stream of (timestamp, value) samples, typically every 10 to 60 seconds. Time series databases compress these extremely well: Gorilla-style encoding stores timestamps as delta-of-deltas and values as XOR against the previous value. Facebook's Gorilla paper measured 1.37 bytes per point against 16 raw (Gorilla, VLDB 2015), and Prometheus averages 1 to 2 bytes per sample (Prometheus storage). Name metrics in base units (seconds, bytes) and end counters in _total (Prometheus naming).
Push versus pull
In the pull model (Prometheus), the server scrapes an HTTP endpoint on every target and notices when one is down. In the push model (StatsD, OTLP to a collector), the application sends metrics outward, which suits short-lived jobs and serverless. The line has blurred: since 3.0 (November 2024) Prometheus can itself receive OTLP pushes on /api/v1/otlp/v1/metrics once started with --web.enable-otlp-receiver (Prometheus OTLP guide).
Push brings a second difference: temporality. Prometheus counters are cumulative (every sample repeats the same start time), while OpenTelemetry SDKs may send delta points that each cover only the last interval (OTel metrics data model). A delta point that gets lost is lost data rather than a wider interval, and Prometheus needs a conversion option to ingest deltas (Prometheus feature flags).
Counters and rates
A counter is a monotonically increasing total since process start. You query its rate over a window: rate(http_requests_total[5m]). Counters survive scrape loss gracefully, since a missed scrape just lengthens the interval, and the query engine handles resets when a process restarts.
Histograms and percentiles
A histogram keeps a counter per bucket (le="0.1", le="0.25", and so on) plus a sum and count. To compute p99 across 200 instances, sum the bucket rates across instances, then interpolate within the bucket where the 99th percentile falls. Accuracy depends on bucket boundaries, so put boundaries near your SLO thresholds. Precomputed client-side summaries give exact per-instance quantiles but cannot be combined across instances (Prometheus histograms). Native (exponential) histograms remove the bucket-choice problem: boundaries grow by a fixed factor, with 2^scale buckets per power of two, so a higher scale means finer resolution (OTel metrics data model). They were experimental for years and became stable in Prometheus 3.8; from 3.9 the feature flag does nothing and each scrape config must set scrape_native_histograms: true (Prometheus native histograms).
Cardinality
Total series is roughly the product of each label's distinct values. method (5) times status (10) times route (50) times 300 pods is 750,000 series for one metric. Add user_id and it becomes unbounded. Each active series costs memory in the TSDB (a few KB in Prometheus), so cardinality, not sample count, is what crashes metric systems. Prometheus's own guidance is never to put unbounded values such as user IDs or email addresses in labels (Prometheus naming).
Down the stack
Layer 1The contract
A metrics system promises that a series is identified exactly by its name and label set, that samples for a series are stored in timestamp order at the scrape or push resolution, and that queries over a range return values computed from those samples. It does not promise every event is counted: a sample summarises an interval, and a scrape that fails leaves a gap. Counter semantics promise that rate() is correct across process restarts, because a drop in value is treated as a reset. Recent data is typically queryable within one scrape interval.
Layer 2The mechanism
Prometheus keeps the last two to three hours in a head block in memory. Each series has an entry in an in-memory index mapping label pairs to series IDs through postings lists, and each series appends samples to a chunk of up to 120 samples (or two hours) (Vernekar, Prometheus TSDB), compressed with the Gorilla scheme: the timestamp is stored as a delta-of-delta (zero bits when the scrape interval is regular), and the value as an XOR with the previous value, storing only the meaningful bits. Since v2.19, full chunks are written to disk and memory-mapped, so only the open chunk per series stays in RAM. All incoming samples are also appended to a write-ahead log in 128 MB segments for crash recovery (Prometheus storage), and local retention defaults to 15 days. Every two hours the head is cut into an immutable block directory with a chunks/ folder, an index file, and meta.json, and older blocks are compacted into larger ones. A query resolves label matchers by intersecting postings lists, then decodes only the chunks overlapping the time range.
Layer 3The machine
The XOR scheme exploits how floats change slowly: if a counter goes from 10,452 to 10,460, the XOR has few significant bits, often encoded in under 2 bytes, compared with 16 bytes for a raw timestamp and float pair. At about 1.3 bytes per sample, a million series at 15-second resolution (5,760 samples each per day) take roughly 7.5 GB per day on disk. Memory is the binding resource: each active series costs its label strings, index postings, and an open chunk, roughly 3 to 8 KB, so 10 million active series need tens of GB of RAM regardless of how few samples they hold. WAL writes are sequential and batched, so ingestion of hundreds of thousands of samples per second needs only modest disk throughput, but WAL replay after a crash reads every segment and can take minutes on large heads.
Why it's built this way
- Operators need cheap, fast answers to "how much, how fast" across fleets, so data is aggregated into numeric samples instead of stored as events.
- Consecutive samples are similar, so delta and XOR encoding shrink them by roughly ten times.
- Recent data is queried most and changes constantly, so it lives in memory with a WAL for durability.
- Each unique label set needs its own index entry and open chunk, so cardinality, not sample volume, sets the cost.
Diagram
flowchart LR APP["Service with client library"] -->|"/metrics endpoint"| SCR["Scraper every 15s"] JOB["Batch job"] -->|"push OTLP"| COL["Collector"] COL --> SCR SCR --> TSDB["TSDB: head block in memory"] TSDB --> BLK["Compressed blocks on disk"] BLK --> LTS["Long-term store, downsampled"] TSDB --> QE["Query engine: rate, histogram_quantile"] QE --> DASH["Dashboards and alerts"]
Worked example
A checkout service runs 300 pods and serves 12,000 requests per second. The team instruments RED metrics:
REQS = Counter("http_requests_total", "Requests", ["route", "method", "code"])
LAT = Histogram("http_request_seconds", "Latency", ["route"],
buckets=[.025, .05, .1, .25, .5, 1, 2.5])
def handle(route, method, fn):
with LAT.labels(route).time():
code = fn()
REQS.labels(route, method, str(code)).inc()Cardinality: 40 routes, 3 methods, 8 status codes gives 960 counter series per pod, though only about 150 combinations occur in practice. The histogram has 40 routes times 10 series (8 buckets including +Inf, plus sum and count), so 400. Per pod roughly 550 series, times 300 pods is 165,000 series. At 15-second scrapes that is 11,000 samples per second, trivial for one Prometheus server.
A developer proposes adding customer_id (2 million values) to the counter. Even if each pod only sees 20,000 customers a day, that is 6 million new series per day, enough to exhaust memory. The answer is to keep per-customer analysis in logs or traces, or a dedicated analytics store.
The SLO is "99 percent of requests under 250 ms". Because 0.25 is a bucket boundary, the fraction of fast requests is exact: sum(rate(bucket{le="0.25"})) divided by sum(rate(count)). No interpolation needed.
In the wild
- Cloudflare, 2023: guarding 916 Prometheus servers against one bad label. Cloudflare runs about 4.9 billion series across 916 Prometheus instances, the largest holding about 30 million. A metric that suddenly gains a high-cardinality label can exhaust memory and crash a server. Their defences: a default
sample_limitof 200 series per scrape, a patch so that going over the limit drops only the new series instead of failing the whole scrape, a hard cap on total series in the TSDB head, and a CI check of capacity before anyone's limit is raised (Cloudflare). - Uber, 2018: rebuilding the metrics store as M3. Uber outgrew a sharded Graphite cluster (manual resharding, no replication, so one dead disk lost metrics for good) and then Cassandra (compaction burned CPU and I/O on data that never changes). M3 handled over 6.6 billion series, aggregating about 500 million metrics per second down to 20 million persisted, with per-metric retention and downsampling applied at ingest rather than at compaction (Uber).
Trade-offs
| Option | Strengths | Weaknesses | Choose when |
|---|---|---|---|
| Pull (Prometheus scrape) | Detects down targets, simple clients, central control | Needs service discovery; awkward for short jobs | Long-running services in Kubernetes |
| Push (OTLP, StatsD) | Works for serverless, batch, and behind NAT | Silent when a source dies; needs aggregation tier | Ephemeral workloads |
| Fixed-bucket histograms | Aggregatable, cheap | Accuracy limited by buckets | Latency SLOs with known thresholds |
| Client-side summaries | Exact per-instance quantiles | Cannot aggregate across instances | Single-instance diagnostics only |
| High-cardinality events (wide events) | Arbitrary slicing by any field | Storage and query cost much higher | Debugging unknown unknowns alongside metrics |
Pitfalls in production
- Cardinality explosion. A label holding user IDs, URLs with IDs, or error messages creates millions of series and the TSDB runs out of memory. Use route templates, bounded enums, and per-metric series limits.
- Averaging percentiles. A dashboard averages p99 across pods and shows a number that is not the fleet p99. Aggregate bucket counters first, then compute the quantile.
- Gauges for counts. Exporting per-interval counts as gauges loses data when scrapes fail. Use counters and
rate(). - Bad bucket boundaries. Buckets at 1, 5, 10 seconds for a 200 ms service make p99 meaningless. Choose boundaries around your SLO, or use native histograms.
- Missing saturation signals. Dashboards show CPU at 40 percent while a thread pool is full. Instrument queue depths, pool usage, and connection limits directly.
Leadership lens
- Standardise naming, units (seconds, bytes), and required labels through a shared library, so every service gets RED metrics for free.
- Put cardinality budgets per team and alert when a metric's series count jumps.
- Decide what belongs in metrics versus logs versus traces. Metrics answer "how much and how fast"; per-entity questions go elsewhere.
- Plan retention and downsampling: raw resolution for 2 to 4 weeks, 5-minute rollups for a year is common.
Check yourself
Why are counters preferred over gauges for request counts?
Show answer
A counter's rate can be computed over any window and missed scrapes only widen the interval, whereas per-interval gauges lose whatever happened between missed samples.
Why can you not average p99 values across instances?
Show answer
Percentiles are not linear; the fleet p99 depends on the full distribution, so you must combine bucket counts and compute the quantile from the merged histogram.
What determines the memory cost of a metric in a TSDB?
Show answer
The number of active series, which is the product of distinct values across its labels, not the number of samples.
Sources and further study
Primary
- OpenTelemetry metrics data model: delta versus cumulative temporality and the exponential histogram layout.
- Prometheus native histograms specification: the bucket schema and the 3.8 and 3.9 changes that made them stable.
Systems docs
- Prometheus storage: blocks, WAL segments, retention flags and bytes per sample.
- Prometheus 3.0 release notes: the OTLP receiver, UTF-8 names and Remote Write 2.0.
- Prometheus metric and label naming: base units, suffixes and the rule against unbounded labels.
- Ganesh Vernekar, Prometheus TSDB: the head block: a maintainer's walk through chunks, memory-mapping and the WAL.
Books
- Site Reliability Engineering, Beyer et al., chapter 10 "Practical Alerting from Time-Series Data": Borgmon, the design Prometheus descends from.
- Systems Performance (2nd edition), Brendan Gregg, chapter 4 "Observability Tools": where resource metrics come from on a Linux host.
Other curricula
- Azure Architecture Center: monitoring and diagnostics: frames metrics by scenario (health, performance, SLA, usage) and stresses consistent instrumentation.
- Grafana: the RED method: Tom Wilkie's per-service method and how it relates to USE and the golden signals.
- donnemartin/system-design-primer: mentions metrics only for load balancing and latency numbers, with no instrumentation section.
Watch
- The RED Method: How To Instrument Your Services (Tom Wilkie, CNCF): the origin of RED and how to apply it consistently.
- Understanding Prometheus Metric Types (PromLabs): counters, gauges, summaries, and histograms explained by a Prometheus co-founder.
Free resources
- Prometheus data model: metric names, labels, and series identity.
- Prometheus histograms and summaries: when to use each and how quantile estimation errs.
- Gorilla: A Fast, Scalable, In-Memory Time Series Database (VLDB 2015): the compression scheme most TSDBs now use.
- The USE Method (Brendan Gregg): a checklist for resource-level instrumentation.