Anyone who has worked with Prometheus, Mimir or any tool in that ecosystem knows that everything starts at a simple HTTP endpoint called /metrics. The application exposes a text file, the collector scrapes it at regular intervals, and each line becomes a time series. That simple contract is the foundation of all metrics-based observability.
What a lot of people don’t know is that there are 4 different metric types, and picking the wrong one leads to alerts that never fire, dashboards that lie, or exploding cardinality. In this post I’ll explain how /metrics works under the hood and when to use Counter, Gauge, Histogram or Summary.
Anatomy of /metrics
First, it’s worth looking at what actually comes out of the endpoint. The format is OpenMetrics (an evolution of Prometheus’s original exposition format), and each metric has 3 parts: # HELP (description), # TYPE (type) and the series themselves.
# HELP http_requests_total Total HTTP requests received# TYPE http_requests_total counterhttp_requests_total{method="GET",status="200"} 1547http_requests_total{method="POST",status="201"} 89http_requests_total{method="GET",status="500"} 3
# HELP process_resident_memory_bytes Resident memory in bytes# TYPE process_resident_memory_bytes gaugeprocess_resident_memory_bytes 4.2598400e+07
# HELP http_request_duration_seconds Request latency# TYPE http_request_duration_seconds histogramhttp_request_duration_seconds_bucket{le="0.1"} 1430http_request_duration_seconds_bucket{le="0.5"} 1520http_request_duration_seconds_bucket{le="1.0"} 1540http_request_duration_seconds_bucket{le="+Inf"} 1547http_request_duration_seconds_sum 187.3http_request_duration_seconds_count 1547Each name + labels combination is an independent time series. The Prometheus scrape takes this snapshot every 15-30s and stores it with a timestamp.
Important point:
/metricsalways shows the current value (cumulative, in the case of counters). Prometheus computes rates and differences at query time, not at collection time.
The 4 metric types
flowchart LR Metric["Metric"] --> Counter["Counter<br/>only goes up"] Metric --> Gauge["Gauge<br/>goes up and down"] Metric --> Histogram["Histogram<br/>distribution<br/>(server-side buckets)"] Metric --> Summary["Summary<br/>distribution<br/>(client-side quantiles)"]
Which one to pick depends on what you want to answer: counting events, measuring an instantaneous value, or understanding the distribution of a measurement.
Counter
A counter is a metric that only goes up (or resets to zero when the process restarts). It’s the most common type and the one most used to count events.
# TYPE http_requests_total counterhttp_requests_total{status="200"} 1547The rule is simple: if the question is “how many times did X happen?”, it’s a counter. By convention, the name ends in _total.
When to use it
- Total number of HTTP requests
- Number of errors, exceptions, retries
- Bytes sent/received
- Messages published/consumed on a queue
- Jobs processed
What NOT to do
You almost never want to plot a counter’s raw value. The number only grows, so the chart becomes a line that climbs forever. What you want is the counter’s rate:
# Requests per second over the last 5 minutesrate(http_requests_total[5m])
# Errors per second, broken down by endpointsum by (endpoint) (rate(http_requests_total{status=~"5.."}[5m]))
# Error rate (errors / total)sum(rate(http_requests_total{status=~"5.."}[5m]))/sum(rate(http_requests_total[5m]))rate() handles counter resets (deploy, crash) automatically and returns units per second. For shorter windows in alerts, there’s irate().
If you catch yourself creating a metric called
requests_per_secondand doing the math in the application, stop. Export arequests_totalcounter and let Prometheus compute the rate. That avoids double aggregation and gives you the flexibility to pick the window later.
Gauge
A gauge is a value that can go up and down freely. It’s a snapshot of “right now”.
# TYPE goroutines_count gaugegoroutines_count 42
# TYPE queue_depth gaugequeue_depth{queue="emails"} 17When to use it
- Memory, CPU, disk usage
- Temperature, voltage
- Queue depth
- Number of active connections
- Number of running pods
- Position in a Kafka queue (lag)
Useful functions
Unlike a counter, the raw value makes sense. You plot it directly. For more sophisticated analysis:
# Change over the last 5 minutes (positive = going up, negative = going down)delta(queue_depth[5m])
# Rate of change per secondderiv(queue_depth[5m])
# Maximum value over the last 1h, grouped by queuemax_over_time(queue_depth[1h])Common gotcha: using
rate()on a gauge makes no sense (and Prometheus won’t warn you). If the metric is “number of pods”,rate()gives you “pods created per second”, which is probably not what you wanted.
Histogram
This is where things get interesting. A histogram measures the distribution of observed values, grouping them into predefined buckets. It’s the right type for latency, payload size and any measurement where you want percentiles.
# TYPE http_request_duration_seconds histogramhttp_request_duration_seconds_bucket{le="0.05"} 24054http_request_duration_seconds_bucket{le="0.1"} 33444http_request_duration_seconds_bucket{le="0.25"} 100392http_request_duration_seconds_bucket{le="0.5"} 129389http_request_duration_seconds_bucket{le="1.0"} 133988http_request_duration_seconds_bucket{le="+Inf"} 144320http_request_duration_seconds_sum 53423.1http_request_duration_seconds_count 144320Notice that the histogram produces 3 different series:
_bucket{le="..."}: a cumulative counter of observations at or below each bound (le= “less than or equal”)._sum: the sum of all observed values._count: the total number of observations (equal to the+Infbucket).
Computing percentiles
The big insight is that buckets let you compute percentiles on the server, aggregating across multiple instances of the application:
# p95 latency over the last 5 minuteshistogram_quantile(0.95, sum by (le) (rate(http_request_duration_seconds_bucket[5m])))
# p99 latency broken down by endpointhistogram_quantile(0.99, sum by (le, endpoint) (rate(http_request_duration_seconds_bucket[5m])))
# Average latency (not a percentile, but useful)rate(http_request_duration_seconds_sum[5m])/rate(http_request_duration_seconds_count[5m])Percentile accuracy depends on the configured buckets. If you only have buckets at [0.1, 1.0, 10.0] and most requests take between 100ms and 200ms, the p95 will be a rough interpolation inside the 0.1-1.0 bucket. Configure buckets that make sense for your distribution.
When to use it
- HTTP/gRPC request latency
- Database query execution time
- Payload size
- Job processing time
- Anything where you want p50, p95, p99
Native histograms (Prometheus 2.40+)
The modern version of the histogram has no fixed buckets: Prometheus maintains exponential buckets automatically, with much higher resolution and much lower cardinality. If you’re starting today on an up-to-date stack, it’s worth turning on --enable-feature=native-histograms. Most OpenTelemetry SDKs already support it.
Summary
A summary also measures distribution, but it computes the quantiles inside the application and exports them directly:
# TYPE rpc_duration_seconds summaryrpc_duration_seconds{quantile="0.5"} 0.052rpc_duration_seconds{quantile="0.9"} 0.198rpc_duration_seconds{quantile="0.99"} 0.812rpc_duration_seconds_sum 8421.3rpc_duration_seconds_count 144320At first glance it looks more convenient: you already have the p99 ready to go, with no need for histogram_quantile(). In practice, summary has a serious limitation: quantiles can’t be aggregated.
You can’t add up the p99 of two instances to get the p99 of the whole set. No math does that from the individual quantiles: you need the original data or the buckets.
That means if you run 5 replicas of your API, each one exports its own p99, and the best you can get is the average or the maximum of those p99s, and neither one is the true p99 of the set.
When to use Summary
Honestly, in 95% of cases: don’t. Histogram is almost always better.
The few cases where summary makes sense:
- You need very precise quantiles (e.g. p99.9) and have only one instance of the service.
- You know in advance exactly which quantile you want, and it will never change.
- The operation’s latency varies across so many orders of magnitude that choosing buckets is impractical.
Histogram vs Summary
flowchart TB
Q["Do I need percentiles?"] -->|Yes| Agg{"Aggregating across<br/>multiple instances?"}
Q -->|No, just counting| Counter["Counter"]
Q -->|No, instantaneous value| Gauge["Gauge"]
Agg -->|Yes| Histogram["Histogram<br/>(prefer native)"]
Agg -->|No, just 1 instance and<br/>I need exact p99.9| Summary["Summary"]
The blunt summary: if you’re in doubt, pick histogram.
Cardinality: the brake pedal
Every unique name + labels combination is a new time series. And that costs memory, disk and money.
# BAD: exploding cardinalityhttp_requests_total{user_id="123", request_id="abc-def-...", path="/api/users/123"} 1With user_id and request_id as labels, you potentially have millions of series. Each series uses memory in Prometheus and grows indefinitely.
Golden rules
- Low-cardinality labels:
method,status,endpoint(after normalizing),region,service. Ideally up to a few hundred unique values. - NEVER labels:
user_id,request_id,trace_id,email, URL content with IDs (/api/users/123instead of/api/users/:id). - To correlate with specific requests: use logs (Loki) or traces (Tempo). Metrics are for aggregated trends.
A good heuristic: if the label’s value changes on almost every request, it shouldn’t be a label.
Estimating the cost
The total number of series for a metric is the product of each label’s cardinality. For example:
http_requests_total{ method, # 5 values (GET, POST, ...) endpoint, # 50 values status # 10 values}# = 5 * 50 * 10 = 2500 seriesAdding region (3 values) takes you to 7500 series. Adding user_id (1 million) takes you to… well, you get it.
Naming conventions
Prometheus has strong conventions that are worth following:
- Snake_case:
http_requests_total, nothttpRequestsTotal. - Counters end in
_total:errors_total, noterrors. - Units in seconds and bytes: prefer
_secondsand_bytesin the name, not_millisecondsor_kilobytes. Grafana knows how to format them. - Unit suffix in the name:
request_duration_seconds,payload_size_bytes. - Component prefix:
http_*,db_*,kafka_*.
# Goodhttp_request_duration_seconds_bucket{...}db_connections_openkafka_consumer_lag_messages
# BadHTTPLatencyMs{...}db_connslagSummary
| Type | What for | Common functions | Watch out for |
|---|---|---|---|
| Counter | Counting events | rate(), increase() | Always _total, don’t plot raw value |
| Gauge | Instantaneous value | direct, delta(), deriv() | Don’t use rate() |
| Histogram | Aggregatable distribution | histogram_quantile() | Well-chosen buckets |
| Summary | Precise percentiles, no aggregation | direct via quantile label | Doesn’t aggregate across instances |
Conclusion
Prometheus’s 4 types look similar but solve different problems. In practice, counter and histogram cover the vast majority of cases: counters for events, histograms for distributions. Gauges come in when you need an instantaneous value, and summary is rare enough to be treated as a special case.
The biggest source of trouble with metrics isn’t picking the wrong type, it’s uncontrolled cardinality. Metrics answer “what’s happening on average”. If you want to answer “what happened in this specific request from customer X”, that’s a job for logs and traces, not metrics.
If you’re starting a new stack, consider:
- Standardizing instrumentation on OpenTelemetry (which already follows Prometheus conventions).
- Turning on native histograms from day one.
- Defining a standard set of allowed labels for the team, with cardinality review in the PR.
The difference between a metrics stack that scales and one that turns into a cost nightmare usually comes down to these early choices.