Skip to main content
Nordlys logo, a drawing of two gray mountains with green northern lights in the background @phpeterle Blog
PT

Back to all posts

Prometheus metrics in /metrics and when to use each one

Published on by Pedro Peterle · 8 min read

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.

/metrics
# HELP http_requests_total Total HTTP requests received
# TYPE http_requests_total counter
http_requests_total{method="GET",status="200"} 1547
http_requests_total{method="POST",status="201"} 89
http_requests_total{method="GET",status="500"} 3
# HELP process_resident_memory_bytes Resident memory in bytes
# TYPE process_resident_memory_bytes gauge
process_resident_memory_bytes 4.2598400e+07
# HELP http_request_duration_seconds Request latency
# TYPE http_request_duration_seconds histogram
http_request_duration_seconds_bucket{le="0.1"} 1430
http_request_duration_seconds_bucket{le="0.5"} 1520
http_request_duration_seconds_bucket{le="1.0"} 1540
http_request_duration_seconds_bucket{le="+Inf"} 1547
http_request_duration_seconds_sum 187.3
http_request_duration_seconds_count 1547

Each 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: /metrics always 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 counter
http_requests_total{status="200"} 1547

The 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 minutes
rate(http_requests_total[5m])
# Errors per second, broken down by endpoint
sum 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_second and doing the math in the application, stop. Export a requests_total counter 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 gauge
goroutines_count 42
# TYPE queue_depth gauge
queue_depth{queue="emails"} 17

When 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 second
deriv(queue_depth[5m])
# Maximum value over the last 1h, grouped by queue
max_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 histogram
http_request_duration_seconds_bucket{le="0.05"} 24054
http_request_duration_seconds_bucket{le="0.1"} 33444
http_request_duration_seconds_bucket{le="0.25"} 100392
http_request_duration_seconds_bucket{le="0.5"} 129389
http_request_duration_seconds_bucket{le="1.0"} 133988
http_request_duration_seconds_bucket{le="+Inf"} 144320
http_request_duration_seconds_sum 53423.1
http_request_duration_seconds_count 144320

Notice that the histogram produces 3 different series:

  1. _bucket{le="..."}: a cumulative counter of observations at or below each bound (le = “less than or equal”).
  2. _sum: the sum of all observed values.
  3. _count: the total number of observations (equal to the +Inf bucket).

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 minutes
histogram_quantile(0.95,
sum by (le) (rate(http_request_duration_seconds_bucket[5m]))
)
# p99 latency broken down by endpoint
histogram_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 summary
rpc_duration_seconds{quantile="0.5"} 0.052
rpc_duration_seconds{quantile="0.9"} 0.198
rpc_duration_seconds{quantile="0.99"} 0.812
rpc_duration_seconds_sum 8421.3
rpc_duration_seconds_count 144320

At 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 cardinality
http_requests_total{user_id="123", request_id="abc-def-...", path="/api/users/123"} 1

With 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/123 instead 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 series

Adding 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, not httpRequestsTotal.
  • Counters end in _total: errors_total, not errors.
  • Units in seconds and bytes: prefer _seconds and _bytes in the name, not _milliseconds or _kilobytes. Grafana knows how to format them.
  • Unit suffix in the name: request_duration_seconds, payload_size_bytes.
  • Component prefix: http_*, db_*, kafka_*.
# Good
http_request_duration_seconds_bucket{...}
db_connections_open
kafka_consumer_lag_messages
# Bad
HTTPLatencyMs{...}
db_conns
lag

Summary

TypeWhat forCommon functionsWatch out for
CounterCounting eventsrate(), increase()Always _total, don’t plot raw value
GaugeInstantaneous valuedirect, delta(), deriv()Don’t use rate()
HistogramAggregatable distributionhistogram_quantile()Well-chosen buckets
SummaryPrecise percentiles, no aggregationdirect via quantile labelDoesn’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.

References