Notes / Prometheus / 02 Prometheus Data Model / 1 Metrics Deep Dive

1 — Metrics Deep Dive

The four Prometheus metric types — Counter, Gauge, Histogram, Summary — walked through hands-on across a Linux batch app + Node Exporter and a Windows web app + Windows Exporter, plus the histogram_quantile bucket-math caveat and the Histogram-vs-Summary tradeoff.

Updated July 18, 2026 · §202607181229-8 ·

1 — Metrics Deep Dive

Time Series Fundamentals previewed the four metric types in one line each. This chapter is where that preview gets cashed in: real definitions, real docker run / exporter commands, and the bucket math that trips people up when they try to turn a histogram into an SLO number.

This chapter is also the single canonical home for the hands-on walkthrough below. Other chapters in this book that touch counters, gauges, histograms, or summaries link back here instead of re-embedding the commands — so if you’re looking for the exact container images and hostnames, this is the page that has them.

Two Demo Environments, One Metrics Format

The walkthrough runs two independent environments that happen to expose metrics through the exact same HTTP-and-text-format mechanism, which is the point: Prometheus doesn’t care whether it’s scraping a Linux container or a Windows exporter.

Environment 1 — Linux batch app + Node Exporter

ssh ps-prom-ub1804

Run the batch processor (a .NET Core console app instrumented with the Prometheus client library):

docker run -d -p 8080:80 --name batch sixeyed/prometheus-demo-batch:linux

docker logs batch

Browse to http://ps-prom-ub1804:8080/metrics. You’ll see custom application metrics (worker_jobs_active, worker_jobs_total) alongside .NET runtime metrics (dotnet_total_memory_bytes, dotnet_collection_count_total) — all in the same text exposition format.

Now add server-level metrics with Node Exporter:

wget https://github.com/prometheus/node_exporter/releases/download/v1.0.0/node_exporter-1.0.0.linux-amd64.tar.gz

tar xvfz node_exporter-1.0.0.linux-amd64.tar.gz

cd node_exporter-1.0.0.linux-amd64/

./node_exporter

Browse to http://ps-prom-ub1804:9100/metrics for hardware/OS metrics: node_disk_io_time_seconds_total, node_cpu_seconds_total, node_filesystem_avail_bytes, and the info metric node_uname_info.

Environment 2 — Windows web app + Windows Exporter

ssh Administrator@ps-prom-win2019

Run the web app (a .NET Core web app, same client library):

docker run -d -p 8080:80 --name web sixeyed/prometheus-demo-web:windows

docker logs web

Browse to http://ps-prom-win2019:8080 (the app itself) and http://ps-prom-win2019:8080/metrics (its metrics). You’ll see dotnet_total_memory_bytes / dotnet_collection_count_total again, plus http_requests_received_total (a counter with method and response-code labels) and http_request_duration_seconds (a histogram of request-processing time).

Then hit the endpoint that deliberately adds latency, and re-check the metrics:

http://ps-prom-win2019:8080?slow
http://ps-prom-win2019:8080/metrics

Now web_delay_seconds (a summary of the injected delay) appears, and http_request_duration_seconds shows the extra response time flowing through its buckets.

Add server-level metrics with Windows Exporter:

iwr -useb -o windows_exporter-0.13.0-amd64.exe https://github.com/prometheus-community/windows_exporter/releases/download/v0.13.0/windows_exporter-0.13.0-amd64.exe

.\windows_exporter-0.13.0-amd64.exe

This needs firewall access on the default port 9182. Browse to http://ps-prom-win2019:9182/metrics — there’s a lot there, so filter it down:

.\windows_exporter-0.13.0-amd64.exe --collectors.enabled "os,cpu,logical_disk"

Refresh http://ps-prom-win2019:9182/metrics and you’ll find windows_logical_disk_read_seconds_total / windows_logical_disk_write_seconds_total (counters with a volume-name label), windows_cpu_time_total (labels for core and work mode), windows_logical_disk_free_bytes (gauge), and the info metric windows_os_info.

With both environments running, four endpoints are live and consistent in format:

EndpointWhat it exposes
http://ps-prom-ub1804:8080/metricsBatch app — custom + .NET runtime metrics
http://ps-prom-ub1804:9100/metricsNode Exporter — Linux server metrics
http://ps-prom-win2019:8080/metricsWeb app — custom + .NET runtime metrics
http://ps-prom-win2019:9182/metricsWindows Exporter — Windows server metrics

Counter

Definition: a value that only ever increases (or resets to zero when the process restarts). It answers “how many times did X happen.”

When to use it: anything you’d describe as a running total — requests served, jobs completed, errors thrown, bytes written. If the question is “how many,” reach for a counter and let rate() or increase() turn the running total into a per-second or per-window figure at query time.

Worked example: in the batch app, worker_jobs_total is a counter with labels distinguishing outcome (it only ever climbs — refresh /metrics a few times and watch the number go up, never down). Node Exporter’s node_disk_io_time_seconds_total{device="sda"} behaves the same way: it accumulates seconds of disk I/O time on device sda and is only ever useful as a rate, not as a raw number — nobody cares that a disk has spent 105 seconds doing I/O since boot, they care whether that number is climbing faster this hour than last hour.

Gauge

Definition: a value that can go up or down. It answers “what is X right now.”

When to use it: current state — active connections, queue depth, memory in use, temperature. If the value can legitimately decrease, it’s a gauge, never a counter.

Worked example: worker_jobs_active in the batch app is a gauge — as jobs start and finish, this number rises and falls, unlike the counter next to it. The same pattern shows up in dotnet_total_memory_bytes (allocated managed memory goes up and down as the GC runs) and in Node Exporter’s node_filesystem_avail_bytes{device, mountpoint} (free disk space, reported in bytes, typically shown in exponential notation for large filesystems). All three are “what is the value right now,” not “how many times has something happened.”

Histogram

Definition: observations bucketed into cumulative, configurable size boundaries, exposed as a family of counters. It answers “how long or how big, distributed how.”

When to use it: latency and size distributions where you need percentiles computed after the fact, at query time, potentially sliced by any label combination — and where you’re willing to pick bucket boundaries in advance and pay the storage cost of one time series per bucket.

Worked example: the web app’s http_request_duration_seconds histogram uses boundaries like 1ms, 2ms, 4ms, 0.1s, 0.25s. Under normal load, nearly everything lands under 4ms; after triggering ?slow, one request lands in the 0.1s–0.25s bucket. On the wire, a histogram looks like this (illustrative counts, same cumulative mechanics http_request_duration_seconds_bucket produces):

http_request_duration_seconds_bucket{le="0.001"}  118
http_request_duration_seconds_bucket{le="0.002"}  142
http_request_duration_seconds_bucket{le="0.004"}  150
http_request_duration_seconds_bucket{le="0.1"}    150
http_request_duration_seconds_bucket{le="0.25"}   151
http_request_duration_seconds_bucket{le="+Inf"}   151
http_request_duration_seconds_sum                 0.62
http_request_duration_seconds_count               151

The critical thing to internalize: buckets are cumulative. The le="0.004" bucket counts every request at or under 4ms — which includes everything already counted in le="0.002" and le="0.001". +Inf counts everything, so it equals _count (they can differ only if negative observations are possible). Each le value is its own time series, which is why histograms cost more than counters or gauges: more buckets means more series.

Turning buckets into numbers

The _count metric is a counter, so query it as a rate, not a raw value:

rate(http_request_duration_seconds_count[5m])

Average latency over the same window divides the rate of the sum by the rate of the count:

rate(http_request_duration_seconds_sum[5m]) / rate(http_request_duration_seconds_count[5m])

The percentage of requests under a specific bucket boundary:

rate(http_request_duration_seconds_bucket{le="0.004"}[5m])
  / ignoring(le) rate(http_request_duration_seconds_count[5m])

And for percentiles rather than a fixed threshold, histogram_quantile() does the interpolation for you:

histogram_quantile(0.95, rate(http_request_duration_seconds_bucket[5m]))

The interpolation caveat that breaks SLO math

histogram_quantile() doesn’t know the true distribution inside a bucket — it assumes observations are spread evenly between the bucket below and the bucket it lands in, and linearly interpolates. That approximation is fine for dashboards, but it is a real problem if you’re trying to prove an SLO was met.

If your SLO is “95% of requests under 0.5s” but your buckets are 0.1, 0.2, 0.5, and histogram_quantile(0.95, ...) returns 0.4, that 0.4 could genuinely be anywhere between the 0.2 and 0.5 boundaries — you cannot tell whether you cleared the SLO with room to spare or barely scraped by. The fix is to put a bucket exactly at the SLO threshold. With a bucket at le="0.5", you can compute the exact fraction of requests at or under 0.5s directly (the percentage-of-bucket query above) instead of trusting an interpolated quantile. What you still won’t get from that is how far over or under the threshold the tail is — for that you’d need more buckets clustered around the threshold, which trades accuracy for cardinality. Every extra le bucket is another time series, so tightening bucket resolution has a direct cost in RAM, disk, and query latency, particularly once you cross that with per-request labels like path or status code.

Summary

Definition: like a histogram, a summary tracks how long or how big something is — but instead of exposing bucket counters, it computes quantiles client-side and streams them out directly, alongside a _sum and a _count.

When to use it: when you need an accurate quantile for a fixed, known set of percentiles and can’t tolerate the interpolation error histograms introduce — and when you’re willing to accept that those percentiles can’t be aggregated across instances (a p95 computed on one process can’t be averaged with a p95 from another to get a fleet-wide p95; a histogram can).

Worked example: web_delay_seconds in the web app is a summary of the artificially injected delay on /?slow. It exposes something like:

web_delay_seconds{quantile="0.5"}   0.011
web_delay_seconds{quantile="0.9"}   0.19
web_delay_seconds{quantile="0.99"}  0.24
web_delay_seconds_sum               1.8
web_delay_seconds_count             150

The quantile label reports the value below which that fraction of observations fell — quantile="0.9" at 0.19 means 90% of observed delays were 0.19s or less. _sum and _count behave exactly like a histogram’s, so the same average-over-time query pattern applies: rate(web_delay_seconds_sum[5m]) / rate(web_delay_seconds_count[5m]). The quantiles themselves, though, were fixed by the client library when the metric was instrumented — you cannot ask for a quantile="0.95" at query time if the app only exports 0.5, 0.9, and 0.99.

Histogram vs. Summary

HistogramSummary
Bucket/quantile choiceBucket boundaries picked by the instrumenter, any quantile computable laterQuantiles must be decided ahead of time in the client
Client-library costLow — just increments countersHigher — computes quantiles in-process
Query-time flexibilityAny quantile, any aggregation across instancesOnly the quantiles exported; cannot be meaningfully averaged across instances
Server-side costPrometheus computes quantiles via histogram_quantile() at query timeMinimal — the number is already computed

The short version: reach for a histogram by default because it’s aggregatable and its cost lives on the server, where you can see and manage it. Reach for a summary only when you need an accurate fixed quantile the moment it’s scraped and you don’t need to aggregate it across instances.

What’s Not Covered Yet

Prometheus’s Native Histograms (sparse, high-resolution histograms that don’t require picking bucket boundaries up front) and Exemplars (trace-ID pointers attached to histogram buckets) are both real, current Prometheus features — but none of the source material this book draws from covers either one, so they’re named here honestly rather than described from guesswork.

Metadata

AuthorAmit Singh
Scopeprometheus

Local graph

Full graph →

Linked from 7 notes

2 — PromQL Functions

Math, date/time, type-conversion, and sorting functions; the rate() vs irate() decision; and the histogram_quantile() function-call mechanics.

2 — Hands-On Labs

A sequenced, hands-on path through the practical material already covered elsewhere in this book, arranged as a lab progression for PCA readiness.

6 — Common Anti-Patterns

Three concrete Prometheus anti-patterns seen in this book's source material — high-cardinality labels, invalid/reserved metric naming, and misaligned histogram_quantile buckets — with why each one breaks and where to read the full explanation.

2 — Time Series Fundamentals

The time series data model behind Prometheus — metric names, labels, timestamps, samples, and how PromQL classifies the data it operates on.

3 — Data Flow

A short connective walk through Prometheus end to end — from an instrumented app exposing a metric, through scraping and storage, to a PromQL query surfaced as an alert or a dashboard panel — with each stage pointing to the chapter that owns it.

2 — Exporters

What a Prometheus exporter is, installing Node Exporter as a systemd service, and monitoring the container runtime itself via Docker Engine metrics and cAdvisor.

Prometheus

A book-shaped table of contents for Prometheus: monitoring foundations through architecture, data model, instrumentation, service discovery, PromQL, alerting, production operation, PCA certification, and MAANG interview prep — cross-linking existing notes instead of duplicating them.