Skip to content

Split View: 질문에 답하는 Prometheus 메트릭 설계 — 타입 선택, 카디널리티 예산, rate 와 분위수의 함정

✨ Learn with Quiz
|

질문에 답하는 Prometheus 메트릭 설계 — 타입 선택, 카디널리티 예산, rate 와 분위수의 함정

들어가며 — 패널은 40개인데 답이 없는 대시보드

장애 대응 중에 대시보드를 엽니다. 패널이 40개 있습니다. CPU, 메모리, 스레드 수, GC 횟수, 커넥션 풀 크기, 힙 사용량, 초당 요청 수. 전부 그래프가 그려져 있습니다. 그런데 지금 알고 싶은 것은 하나입니다. "사용자가 실패를 겪고 있는가, 겪고 있다면 몇 퍼센트인가."

그 답이 저 40개 패널 어디에도 없습니다.

메트릭 설계의 문제는 데이터가 부족한 것이 아니라, 수집한 데이터로 답할 수 있는 질문과 답해야 하는 질문이 어긋나 있는 것입니다. 이 글은 그 어긋남을 줄이는 방법을 다룹니다. Prometheus 3.13.0 LTS 기준으로 확인했고, 네이티브 히스토그램은 3.8.0에서 안정화되었지만 스크레이프는 여전히 명시적 옵션으로 켜야 합니다.

메트릭이 답할 수 있는 질문의 모양

메트릭은 시간에 따라 변하는 숫자의 집계입니다. 이 정의에서 답할 수 있는 질문의 모양이 정해집니다.

질문메트릭이 답하는가이유
언제부터 나빠졌는가답한다시간축이 연속적이고 과거가 보존됩니다
몇 퍼센트의 요청이 영향을 받는가답한다집계값이 곧 비율입니다
어제 같은 시간과 비교해 어떤가답한다저장 비용이 낮아 장기 보존이 됩니다
이 사용자의 요청이 왜 실패했는가답하지 못한다개별 이벤트가 집계되어 사라졌습니다
느린 요청이 어느 서비스에서 시간을 썼는가답하지 못한다서비스별 통계는 같은 요청의 것이라는 보장이 없습니다
어떤 입력값이 이 분기를 태웠는가답하지 못한다값이 레이블에 없으면 복원 불가입니다

마지막 세 줄이 중요합니다. 그 질문에 답하려고 레이블을 늘리는 순간 메트릭은 로그나 트레이스의 열등한 대체품이 됩니다. 메트릭의 한계는 카디널리티가 정합니다. 요청마다 값이 달라지는 것을 레이블에 넣기 시작하면, 그것은 이미 메트릭이 아니라 압축률이 나쁜 이벤트 저장소입니다.

시작점은 질문 목록입니다. 온콜이 새벽 3시에 던지는 질문 다섯 개를 먼저 적고, 그 다섯 개에 답하는 메트릭만 만듭니다.

  1. 사용자가 실패를 겪고 있는가, 몇 퍼센트인가
  2. 느려졌는가, 어느 라우트가 느려졌는가
  3. 언제부터인가, 배포와 시각이 겹치는가
  4. 용량 한계에 닿았는가 (큐 길이, 커넥션 풀, 디스크)
  5. 의존하는 외부 서비스 중 문제가 있는 곳이 있는가

카운터, 게이지, 히스토그램 — 잘못 고르면 계산이 불가능해진다

타입 선택은 취향이 아닙니다. 잘못 고르면 나중에 하고 싶은 계산이 원리적으로 불가능해집니다.

카운터는 단조 증가만 합니다. 프로세스가 재시작하면 0으로 돌아갑니다. 값 자체는 의미가 없고 rate 로 초당 변화율을 봐야 의미가 생깁니다. 요청 수, 에러 수, 처리한 바이트 수, 재시도 횟수가 여기 해당합니다.

게이지는 오르내립니다. 현재 시점의 상태를 나타냅니다. 큐 길이, 활성 커넥션 수, 메모리 사용량, 온도가 여기 해당합니다.

히스토그램은 관측값의 분포를 버킷 카운터의 집합으로 기록합니다. 지연 시간, 응답 크기처럼 "분위수를 알고 싶은 값"에 씁니다.

가장 흔한 실수는 지연 시간을 게이지로 기록하는 것입니다.

# 나쁨 — 마지막 요청의 지연 시간만 남는다. 분위수도 평균도 계산할 수 없다
from prometheus_client import Gauge

last_latency = Gauge("http_request_duration_seconds", "요청 처리 시간")

def handle(req):
    t0 = time.monotonic()
    resp = process(req)
    last_latency.set(time.monotonic() - t0)   # 앞선 값들은 전부 사라진다
    return resp

스크레이프 간격이 15초이고 초당 요청이 500건이면, 7500건 중 1건의 값만 저장됩니다. 나머지는 존재한 적도 없는 것이 됩니다. 이 시계열로는 p99를 구할 수 없고, 나중에 데이터를 다시 처리해도 복원되지 않습니다.

# 좋음 — 히스토그램은 모든 관측을 버킷에 누적한다
from prometheus_client import Counter, Histogram

REQUESTS = Counter(
    "http_requests_total", "총 요청 수",
    ["method", "route", "status_class"],
)
LATENCY = Histogram(
    "http_request_duration_seconds", "요청 처리 시간",
    ["method", "route"],
    buckets=(0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1.0, 2.5, 5.0, 10.0),
)

def handle(req):
    route = req.route_template          # "/v1/orders/:id" — 실제 ID 가 아니다
    with LATENCY.labels(req.method, route).time():
        resp = process(req)
    REQUESTS.labels(req.method, route, f"{resp.status // 100}xx").inc()
    return resp

status_class 에 주목합니다. 상태 코드를 그대로 넣으면 값이 수십 가지가 되지만, 대부분의 질문은 2xx인지 5xx인지만 필요로 합니다. 세부 코드가 필요한 순간은 조사할 때이고, 그때는 로그를 봅니다.

알고 싶은 것타입흔한 오답오답의 결과
초당 요청 수카운터게이지로 직전 초의 요청 수스크레이프 사이의 요청이 사라집니다
지연 시간 분위수히스토그램게이지로 마지막 값분위수 계산 불가
지연 시간 분위수히스토그램애플리케이션이 계산한 p99 게이지인스턴스 간 집계 불가
현재 큐 길이게이지카운터로 누적 인큐 수현재 적체를 알 수 없습니다
총 처리량카운터게이지로 초당 처리량스크레이프 누락 시 그 구간이 소실
배치 작업 성공 여부카운터 + 타임스탬프 게이지성공 시에만 카운터 증가실패와 미실행을 구분 못 함

마지막 줄은 배치 잡에서 반복되는 문제입니다. 실패했을 때도 카운터를 올려야 "돌았는데 실패함"과 "아예 안 돎"을 구분할 수 있습니다. 마지막 성공 시각을 게이지로 남기는 것도 함께 합니다.

JOB_RUNS = Counter("batch_job_runs_total", "배치 실행 횟수", ["job", "result"])
JOB_LAST_SUCCESS = Gauge("batch_job_last_success_timestamp_seconds", "마지막 성공 시각", ["job"])

def run_job(name, fn):
    try:
        fn()
        JOB_RUNS.labels(name, "success").inc()
        JOB_LAST_SUCCESS.labels(name).set(time.time())
    except Exception:
        JOB_RUNS.labels(name, "failure").inc()
        raise

레이블 카디널리티 예산

카디널리티는 감이 아니라 곱셈입니다. 메트릭 하나의 시계열 수는 레이블 값 개수의 곱이고, 여기에 인스턴스 수가 곱해집니다.

http_request_duration_seconds_bucket
  route          120개 (라우트 템플릿)
  method           5개
  le              11개 (버킷 경계 + Inf)
  instance        40개 (파드 수)
  ------------------------------------
  = 120 * 5 * 11 * 40 = 264,000 시계열

여기에 _sum 과 _count 가 추가된다
  120 * 5 * 40 * 2 = 48,000
  ------------------------------------
합계 약 312,000 시계열 — 메트릭 하나에서

Prometheus 하나가 다루는 활성 시계열은 대략 수백만 개 규모에서 메모리와 쿼리 응답성이 급격히 나빠집니다. 정확한 한계는 하드웨어와 쿼리 패턴에 따라 다르지만, 메트릭 하나가 30만 시계열을 쓴다면 그 하나로 예산의 상당 부분이 사라진다는 점이 중요합니다.

절대 넣으면 안 되는 레이블은 값 집합이 무한에 가까운 것들입니다.

  • 사용자 ID, 세션 ID, 요청 ID, 트레이스 ID
  • 정규화되지 않은 URL 경로 — /v1/orders/A-99183
  • 이메일, 전화번호, 주문 번호
  • 타임스탬프나 그것을 포함한 문자열
  • 에러 메시지 원문 — 스택 트레이스 조각이 섞이면 값이 사실상 무한합니다
  • 자유 형식 쿼리 문자열

경계선에 있는 것들도 있습니다. pod 이나 instance 는 파드 수만큼 곱해지고, 오토스케일링과 롤링 배포로 값이 계속 바뀌므로 시간이 지날수록 누적됩니다. 인스턴스별 구분이 진짜로 필요한 메트릭에만 남기고, 나머지는 레코딩 룰로 집계한 뒤 원본을 짧게 보존하는 전략이 현실적입니다.

현재 상태를 진단하는 쿼리들입니다.

# 시계열이 가장 많은 메트릭 상위 10개
topk(10, count by (__name__)({__name__=~".+"}))

# 특정 메트릭에서 어떤 레이블이 카디널리티를 만드는가
count(count by (route) (http_requests_total))
count(count by (instance) (http_requests_total))
count(count by (status) (http_requests_total))

# 전체 활성 시계열 추이 — 계단식으로 뛰면 새 레이블이 들어온 것이다
prometheus_tsdb_head_series

# 스크레이프 대상별로 몇 개의 샘플을 주는가 (상한을 넘으면 스크레이프가 거부된다)
topk(20, scrape_samples_scraped)

prometheus_tsdb_head_series 는 알림을 걸어 둘 가치가 있습니다. 카디널리티 사고는 대부분 배포 직후에 계단식으로 나타나므로, 배포와 시각을 맞춰 보면 원인 커밋을 바로 찾을 수 있습니다.

수집 시점에서 막는 방법도 있습니다. 스크레이프 설정에 샘플 수 상한을 걸면, 폭발한 대상이 Prometheus 전체를 무너뜨리는 것을 막습니다.

# prometheus.yml
scrape_configs:
  - job_name: checkout-api
    sample_limit: 20000              # 이 대상이 이보다 많이 주면 스크레이프 실패로 처리
    label_limit: 24
    label_value_length_limit: 256
    scrape_interval: 15s
    metric_relabel_configs:
      # 사고 대응용 — 문제가 된 레이블을 수집 시점에 지운다.
      # labeldrop 이 아니라 replace 로 빈 값을 넣는 이유: labeldrop 은
      # 레이블 "이름"만 보고 매칭해서 이 job 의 모든 메트릭에서 user_id 를
      # 지운다. 아래처럼 쓰면 http_requests_total 에서만 지울 수 있다.
      # 빈 값 레이블은 Prometheus 데이터 모델에서 없는 레이블과 같다.
      - source_labels: [__name__]
        regex: 'http_requests_total'
        target_label: user_id
        replacement: ''
        action: replace
      # 아예 필요 없는 메트릭은 버린다
      - source_labels: [__name__]
        regex: 'go_gc_duration_seconds.*|python_gc_.*'
        action: drop

sample_limit 이 걸리면 그 대상의 스크레이프가 통째로 실패하므로, 값은 넉넉하게 잡되 반드시 걸어 둡니다. 상한이 없으면 한 서비스의 실수가 전체 모니터링을 중단시킵니다.

rate 가 조용히 틀린 답을 주는 조건

rate 는 range vector의 첫 샘플과 마지막 샘플로 초당 증가율을 구하고, 카운터 리셋을 보정합니다. 함정은 세 가지입니다.

함정 1 — 윈도우가 스크레이프 간격에 비해 좁다

rate 는 윈도우 안에 최소 두 개의 샘플이 필요합니다. 스크레이프 간격이 15초인데 윈도우가 20초면, 샘플이 하나만 들어오는 순간이 생겨 결과가 비어 버립니다. 그래프에는 구멍으로, 알림에서는 "조건이 성립하지 않음"으로 나타납니다.

# 위험 — 스크레이프 간격이 15s 일 때 [20s] 는 샘플이 1개인 순간이 생긴다
rate(http_requests_total[20s])

# 안전 — 스크레이프 간격의 4배 이상. 알림에는 [5m] 이상을 권한다
rate(http_requests_total[1m])
rate(http_requests_total[5m])

경험칙은 간단합니다. 알림에는 5분 이상, 대시보드에는 Grafana의 rate 간격 변수를 씁니다. 4배 규칙은 스크레이프 한두 번이 누락돼도 결과가 살아남게 합니다.

함정 2 — rate 를 sum 뒤에 적용한다

이 실수는 결과가 그럴듯해서 오래 살아남습니다.

# 틀림 — 카운터를 먼저 더하면 인스턴스 재시작(리셋)이 감지되지 않는다
rate(sum(http_requests_total) by (route)[5m:])

# 맞음 — rate 를 먼저, 집계는 그다음
sum(rate(http_requests_total[5m])) by (route)

카운터 리셋 보정은 개별 시계열 단위로만 정확합니다. 파드 하나가 재시작하면 그 시계열은 0으로 떨어지는데, 이미 합산된 뒤에는 "전체가 조금 줄었다"로만 보여서 리셋으로 인식되지 않습니다. 결과는 실제보다 낮게 나온 요청률이고, 배포 직후마다 트래픽이 줄어든 것처럼 보입니다.

함정 3 — 카운터를 그대로 그린다

# 그래프가 계속 우상향하는 직선이 나온다. 아무 정보도 없다
http_requests_total

# 초당 증가율 — 이것이 보고 싶었던 값이다
sum(rate(http_requests_total[5m])) by (route)

# 특정 구간의 총 증가량 — 알림 문구에 쓰기 좋다
sum(increase(http_requests_total{status_class="5xx"}[1h])) by (route)

increaserate 에 윈도우 길이를 곱한 것입니다. 따라서 정수가 아닌 값이 나옵니다. 에러가 3건 발생했는데 increase 가 3.4를 반환하는 것은 버그가 아니라 외삽의 결과입니다. "정확히 N건"이 필요한 계산에는 쓰지 않습니다.

irate 는 마지막 두 샘플만 봅니다. 대시보드에서 순간 반응을 볼 때는 쓸모가 있지만 알림에는 절대 쓰지 않습니다. 노이즈 한 번에 발화합니다.

histogram_quantile 이 조용히 틀린 답을 주는 조건

분위수 계산은 함정이 더 많습니다.

함정 1 — le 를 집계에서 빼먹는다

# 틀림 — le 를 버리면 버킷이 뭉개져서 의미 없는 숫자가 나온다
histogram_quantile(0.99, sum(rate(http_request_duration_seconds_bucket[5m])) by (route))

# 맞음 — le 는 반드시 남긴다
histogram_quantile(0.99,
  sum(rate(http_request_duration_seconds_bucket[5m])) by (le, route)
)

이 실수는 에러를 내지 않고 숫자를 반환합니다. 그래서 대시보드에 몇 달씩 남아 있습니다.

함정 2 — 버킷 경계 밖에서 추정한다

histogram_quantile 은 버킷 사이를 선형 보간합니다. p99가 마지막 유한 버킷보다 위에 있으면 함수는 그 마지막 경계값을 반환합니다. 실제 p99가 8초인데 마지막 버킷이 2.5초라면 결과는 영원히 2.5초입니다.

# 진단 — Inf 버킷과 마지막 유한 버킷의 비율을 본다
sum(rate(http_request_duration_seconds_bucket{le="+Inf"}[5m])) by (route)
-
sum(rate(http_request_duration_seconds_bucket{le="10.0"}[5m])) by (route)

이 값이 0보다 크면 마지막 버킷을 넘는 요청이 있다는 뜻이고, p99가 신뢰할 수 없는 상태일 수 있습니다.

반대로 버킷이 너무 성기면 보간 오차가 큽니다. 버킷이 0.1과 1.0뿐인데 요청 대부분이 0.15초에 몰려 있으면, p99 추정은 0.1과 1.0 사이를 선형으로 자르므로 실제와 크게 어긋납니다. 버킷은 SLO 임계값 주변을 촘촘하게 잡습니다. 300ms를 목표로 한다면 0.2, 0.25, 0.3, 0.4, 0.5가 버킷에 있어야 합니다.

함정 3 — 분위수를 평균 낸다

# 틀림 — 분위수는 산술 평균이 성립하지 않는다
avg(histogram_quantile(0.99, ...))

# 맞음 — 버킷 카운터를 먼저 합치고 나서 분위수를 구한다
histogram_quantile(0.99, sum(rate(http_request_duration_seconds_bucket[5m])) by (le))

인스턴스별 p99의 평균은 전체 p99가 아닙니다. 히스토그램이 가치 있는 이유가 정확히 이것입니다. 버킷 카운터는 더할 수 있고, 더한 뒤에 분위수를 구하면 전체 분포에 대한 추정이 됩니다. 애플리케이션이 자체 계산한 p99 게이지를 노출하면 이 성질을 잃습니다.

네이티브 히스토그램

Prometheus 3.8.0부터 네이티브 히스토그램이 안정 기능입니다. 버킷 경계를 지수 스키마로 자동 관리하므로 경계를 직접 고를 필요가 없고, 하나의 시계열로 표현되어 카디널리티가 크게 줄어듭니다. 다만 스크레이프는 명시적으로 켜야 합니다.

# prometheus.yml
global:
  scrape_interval: 15s

scrape_configs:
  - job_name: checkout-api
    # 네이티브 히스토그램 스크레이프는 명시적 opt-in
    scrape_native_histograms: true
    static_configs:
      - targets: ['checkout-api:8000']
# 고정 버킷: _bucket 시계열에 le 레이블
histogram_quantile(0.99, sum(rate(http_request_duration_seconds_bucket[5m])) by (le, route))

# 네이티브 히스토그램: le 없이 메트릭 자체를 넘긴다
histogram_quantile(0.99, sum(rate(http_request_duration_seconds[5m])) by (route))
항목고정 버킷 히스토그램네이티브 히스토그램
시계열 수버킷 수 곱하기 레이블 조합레이블 조합당 1개
버킷 설계미리 골라야 하고 바꾸면 과거와 불연속자동, 해상도만 지정
범위 밖 값마지막 경계에서 잘림지수 스키마로 넓게 커버
도구 호환성어디서나 동작스크레이프 opt-in 필요, 일부 도구 미지원
이관 난이도기준선쿼리와 대시보드 수정 필요

전환은 신규 메트릭부터 하는 편이 안전합니다. 기존 메트릭을 바꾸면 그 시점에 과거 데이터와 쿼리가 불연속이 됩니다.

레코딩 룰 — 언제, 어떤 이름으로

레코딩 룰은 계산을 쿼리 시점에서 평가 시점으로 옮깁니다. 만들 기준은 넷입니다.

  1. 같은 표현식이 세 곳 이상에서 반복될 때
  2. 쿼리 실행이 2초를 넘을 때
  3. 알림 규칙이 무거운 표현식을 매 평가마다 돌릴 때
  4. 고카디널리티 원본을 집계해서 장기 보존하고 싶을 때

이름은 수준:메트릭:연산 규약을 따릅니다. 콜론은 레코딩 룰에만 쓰고 원본 메트릭 이름에는 절대 쓰지 않습니다. 이 규약을 지키면 이름만 보고 무엇이 남아 있는 차원인지 알 수 있습니다.

# rules/http.yml
groups:
  - name: http_sli
    interval: 30s
    rules:
      # 계층 1 — 원본에서 한 번만 집계한다
      - record: route:http_requests:rate5m
        expr: sum(rate(http_requests_total[5m])) by (route)

      - record: route:http_requests_errors:rate5m
        expr: sum(rate(http_requests_total{status_class="5xx"}[5m])) by (route)

      # 계층 2 — 계층 1 을 참조한다. 원본을 다시 스캔하지 않는다
      - record: route:http_error_ratio:rate5m
        expr: |
          route:http_requests_errors:rate5m
            /
          route:http_requests:rate5m

      - record: route:http_request_duration_seconds:p99_rate5m
        expr: |
          histogram_quantile(0.99,
            sum(rate(http_request_duration_seconds_bucket[5m])) by (le, route)
          )

      # 서비스 전체 — 라우트 차원을 없앤 요약
      - record: service:http_error_ratio:rate5m
        expr: |
          sum(rate(http_requests_total{status_class="5xx"}[5m]))
            /
          sum(rate(http_requests_total[5m]))

계층 구조가 핵심입니다. 계층 1을 여러 규칙이 재사용하면 원본 시계열 스캔이 한 번으로 끝납니다. 다만 규칙끼리 순환 참조를 만들면 안 되고, promtool 이 이것을 잡아 주지 못하므로 리뷰에서 확인해야 합니다.

레코딩 룰이 만드는 시계열 수는 배포 전에 계산합니다. 라우트가 120개인데 5개 윈도우에 대해 각각 규칙을 만들면 600개의 새 시계열입니다. 규칙이 50개면 3만 개입니다.

검증은 CI에서 돌립니다.

# 문법 검사
promtool check rules rules/http.yml

# 단위 테스트 — 입력 시계열을 주고 기대값을 검증한다
promtool test rules tests/http_test.yml

# 전체 설정 검사
promtool check config prometheus.yml
# tests/http_test.yml
rule_files:
  - ../rules/http.yml

evaluation_interval: 30s

tests:
  - interval: 15s
    input_series:
      - series: 'http_requests_total{route="/v1/orders", status_class="2xx"}'
        values: '0+150x40'
      - series: 'http_requests_total{route="/v1/orders", status_class="5xx"}'
        values: '0+3x40'
    promql_expr_test:
      - expr: route:http_error_ratio:rate5m
        eval_time: 8m
        exp_samples:
          - labels: 'route:http_error_ratio:rate5m{route="/v1/orders"}'
            value: 0.0196078431372549

기대값을 직접 계산해 두면, 나중에 누군가 규칙을 "최적화"하다가 의미를 바꿨을 때 CI가 잡아냅니다.

대시보드에 올리기 전에 스스로 묻는 다섯 개의 질문

패널을 하나 만들 때마다 아래를 통과시킵니다. 통과하지 못하면 그 패널은 만들지 않습니다.

  1. 이 패널이 답하는 질문을 한 문장으로 쓸 수 있는가. "CPU 사용률"은 질문이 아닙니다. "이 서비스가 CPU 한계에 닿아서 지연이 생기고 있는가"가 질문입니다.
  2. 값이 나빠졌을 때 무엇을 할지 정해져 있는가. 볼 수는 있지만 아무 행동도 유발하지 않는 지표는 대시보드가 아니라 탐색 쿼리로 남깁니다.
  3. 이 값이 사용자 경험과 어떻게 연결되는가. GC 횟수는 그 자체로는 아무 의미가 없습니다. 지연 시간과 함께 놓여야 의미가 생깁니다.
  4. 정상 범위를 아는가. 정상이 무엇인지 모르면 이상도 알 수 없습니다. 축의 범위와 임계선을 함께 정합니다.
  5. 이 쿼리가 카운터를 그대로 그리거나 분위수를 평균 내고 있지 않은가. 앞의 두 절에서 다룬 함정을 한 번 더 확인합니다.

패널 순서도 질문 순서를 따릅니다. 맨 위는 사용자 관점 지표, 그 아래가 원인 후보, 맨 아래가 인프라 자원입니다. 위에서 아래로 읽으면 "영향이 있는가, 어디서 오는가, 자원 문제인가"의 흐름이 됩니다.

마치며 — 메트릭의 가치는 개수가 아니라 질문 대응에서 나온다

메트릭 300개짜리 시스템에서 온콜이 답을 못 찾는 일은 흔합니다. 반대로 잘 고른 20개로 대부분의 장애를 좁혀 나가는 팀도 있습니다. 차이는 수집량이 아니라, 각 메트릭이 어떤 질문에 답하려고 존재하는지가 명시되어 있느냐입니다.

지금 할 수 있는 가장 값싼 점검은 두 가지입니다. 첫째, 시계열 수 상위 10개 메트릭을 뽑아 각각 "이걸로 어떤 질문에 답하는가"를 적어 봅니다. 답이 안 나오는 것이 대개 비용의 절반을 차지합니다. 둘째, 대시보드에서 histogram_quantile 이 들어간 쿼리를 전부 검색해 by (le 가 있는지 확인합니다. 하나쯤은 빠져 있습니다.

더 파고들 자료입니다.

Designing Prometheus Metrics That Answer Questions — Choosing Types, Cardinality Budgets, and the Traps in rate and Quantiles

Introduction — 40 Panels, and Still No Answer

You open the dashboard while responding to an incident. There are 40 panels. CPU, memory, thread count, GC count, connection pool size, heap usage, requests per second. Every one of them has a graph drawn. But there's exactly one thing you want to know right now: "are users experiencing failures, and if so, what percentage."

That answer isn't in any of those 40 panels.

The problem with metric design isn't a shortage of data — it's a mismatch between the questions the collected data can answer and the questions that need answering. This post covers how to close that gap. Verified against Prometheus 3.13.0 LTS; native histograms stabilized in 3.8.0, but scraping them still has to be turned on with an explicit option.

The Shape of Questions a Metric Can Answer

A metric is an aggregate of a number that changes over time. This definition sets the shape of the questions it can answer.

QuestionCan a metric answer itWhy
Since when has it gotten worseYesThe time axis is continuous and the past is preserved
What percentage of requests are affectedYesAn aggregate value is itself a ratio
How does it compare to the same time yesterdayYesStorage is cheap, so long-term retention is feasible
Why did this specific user's request failNoThe individual event was aggregated away
Where did a slow request spend its time, service by serviceNoPer-service statistics have no guarantee of belonging to the same request
Which input value triggered this branchNoIf the value isn't in a label, it can't be recovered

The last three rows matter. The moment you add labels to try to answer those questions, a metric becomes an inferior substitute for logs or traces. Cardinality is what sets a metric's limit. Once you start putting anything that varies per request into a label, what you have isn't a metric anymore — it's an event store with a bad compression ratio.

The starting point is a list of questions. Write down the five questions on-call asks at 3am first, and build only the metrics that answer those five.

  1. Are users experiencing failures, and what percentage
  2. Has it gotten slower, and on which route
  3. Since when, and does it overlap with a deploy time
  4. Has it hit a capacity limit (queue length, connection pool, disk)
  5. Is anything among the external services it depends on having trouble

Counters, Gauges, Histograms — Pick Wrong and a Calculation Becomes Impossible

Choosing a type isn't a matter of taste. Pick wrong, and a calculation you want to do later becomes impossible in principle.

A counter only ever increases monotonically. It resets to 0 when the process restarts. The value itself is meaningless — it becomes meaningful once you look at the per-second rate of change with rate. Request count, error count, bytes processed, and retry count fall into this category.

A gauge goes up and down. It represents the state at the current moment. Queue length, active connection count, memory usage, and temperature fall into this category.

A histogram records the distribution of observations as a set of bucket counters. Use it for "values you want to know the quantiles of," like latency or response size.

The most common mistake is recording latency as a gauge.

# Bad — only the last request's latency survives. You can compute neither a quantile nor an average
from prometheus_client import Gauge

last_latency = Gauge("http_request_duration_seconds", "Request processing time")

def handle(req):
    t0 = time.monotonic()
    resp = process(req)
    last_latency.set(time.monotonic() - t0)   # every prior value is simply gone
    return resp

With a 15-second scrape interval and 500 requests per second, only 1 out of 7,500 values gets stored. The rest end up as if they'd never existed. You can't compute p99 from this time series, and reprocessing the data later won't bring it back.

# Good — a histogram accumulates every observation into buckets
from prometheus_client import Counter, Histogram

REQUESTS = Counter(
    "http_requests_total", "Total request count",
    ["method", "route", "status_class"],
)
LATENCY = Histogram(
    "http_request_duration_seconds", "Request processing time",
    ["method", "route"],
    buckets=(0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1.0, 2.5, 5.0, 10.0),
)

def handle(req):
    route = req.route_template          # "/v1/orders/:id" — not the actual ID
    with LATENCY.labels(req.method, route).time():
        resp = process(req)
    REQUESTS.labels(req.method, route, f"{resp.status // 100}xx").inc()
    return resp

Notice status_class. Put the status code in as-is, and you get dozens of values, but most questions only need to know 2xx versus 5xx. The moment you need the specific code is when you're investigating, and that's when you look at the logs.

What you want to knowTypeCommon wrong answerConsequence of the wrong answer
Requests per secondCounterA gauge holding the last second's request countRequests between scrapes vanish
Latency quantileHistogramA gauge holding the last valueQuantile computation is impossible
Latency quantileHistogramAn application-computed p99 gaugeCan't aggregate across instances
Current queue lengthGaugeA counter accumulating total enqueue countYou can't tell current backlog
Total throughputCounterA gauge holding per-second throughputA missed scrape loses that window entirely
Whether a batch job succeededCounter + a timestamp gaugeIncrementing a counter only on successCan't distinguish failure from never having run

The last row is a problem that keeps recurring in batch jobs. You have to increment the counter on failure too, or you can't distinguish "ran but failed" from "never ran at all." Also keep a gauge of the last success timestamp alongside it.

JOB_RUNS = Counter("batch_job_runs_total", "Batch run count", ["job", "result"])
JOB_LAST_SUCCESS = Gauge("batch_job_last_success_timestamp_seconds", "Last success timestamp", ["job"])

def run_job(name, fn):
    try:
        fn()
        JOB_RUNS.labels(name, "success").inc()
        JOB_LAST_SUCCESS.labels(name).set(time.time())
    except Exception:
        JOB_RUNS.labels(name, "failure").inc()
        raise

Label Cardinality Budgets

Cardinality isn't a gut feeling — it's multiplication. The number of time series for one metric is the product of the label value counts, and that's further multiplied by the instance count.

http_request_duration_seconds_bucket
  route          120 (route templates)
  method           5
  le              11 (bucket boundaries + Inf)
  instance        40 (pod count)
  ------------------------------------
  = 120 * 5 * 11 * 40 = 264,000 time series

Add _sum and _count on top of this
  120 * 5 * 40 * 2 = 48,000
  ------------------------------------
Total roughly 312,000 time series — from one metric

A single Prometheus starts seeing memory and query responsiveness degrade sharply somewhere around a few million active time series. The exact limit depends on hardware and query patterns, but what matters is this: if one metric uses 300,000 time series, that one metric alone eats up a substantial chunk of the budget.

Labels you must never add are the ones whose value set is close to infinite.

  • User ID, session ID, request ID, trace ID
  • An unnormalized URL path — /v1/orders/A-99183
  • Email, phone number, order number
  • A timestamp, or a string containing one
  • Raw error message text — mix in a fragment of a stack trace and the value set is effectively infinite
  • A free-form query string

Some sit right on the border. pod or instance multiplies by the pod count, and since autoscaling and rolling deploys keep changing the values, it accumulates over time. A practical strategy is to keep it only on metrics that genuinely need per-instance distinction, and for everything else, aggregate with a recording rule and keep the raw data short-lived.

Here are queries to diagnose the current state.

# Top 10 metrics by time-series count
topk(10, count by (__name__)({__name__=~".+"}))

# Which labels create cardinality for a specific metric
count(count by (route) (http_requests_total))
count(count by (instance) (http_requests_total))
count(count by (status) (http_requests_total))

# Trend of total active time series — a step jump means a new label got introduced
prometheus_tsdb_head_series

# How many samples each scrape target sends (scrape gets rejected past the cap)
topk(20, scrape_samples_scraped)

prometheus_tsdb_head_series is worth alerting on. Cardinality incidents mostly show up as a step jump right after a deploy, so lining up the timing with deploys lets you find the culprit commit right away.

There's also a way to block it at collection time. Set a sample-count cap in the scrape config, and it stops a target that's exploded from bringing down all of Prometheus.

# prometheus.yml
scrape_configs:
  - job_name: checkout-api
    sample_limit: 20000              # if this target sends more than this, the scrape is treated as a failure
    label_limit: 24
    label_value_length_limit: 256
    scrape_interval: 15s
    metric_relabel_configs:
      # For incident response — strip a problem label at collection time.
      # Why replace with an empty value instead of labeldrop: labeldrop matches purely on the label
      # "name," so it would strip user_id from every metric in this job. Written as below,
      # it can be stripped from http_requests_total alone.
      # An empty-valued label is the same as a missing label in Prometheus's data model.
      - source_labels: [__name__]
        regex: 'http_requests_total'
        target_label: user_id
        replacement: ''
        action: replace
      # Drop metrics that aren't needed at all
      - source_labels: [__name__]
        regex: 'go_gc_duration_seconds.*|python_gc_.*'
        action: drop

Hitting sample_limit fails the entire scrape for that target, so set the value generously, but always set it. Without a cap, one service's mistake can halt monitoring across the board.

Conditions Under Which rate Silently Gives the Wrong Answer

rate computes the per-second rate of increase from a range vector's first and last samples, and corrects for counter resets. There are three traps.

Trap 1 — The Window Is Narrow Relative to the Scrape Interval

rate needs at least two samples inside the window. If the scrape interval is 15 seconds and the window is 20 seconds, there are moments when only one sample falls in, and the result comes back empty. This shows up as a gap on a graph, and as "the condition never holds" in an alert.

# Dangerous — with a 15s scrape interval, a [20s] window has moments with only 1 sample
rate(http_requests_total[20s])

# Safe — at least 4x the scrape interval. For alerts, [5m] or more is recommended
rate(http_requests_total[1m])
rate(http_requests_total[5m])

The rule of thumb is simple: use 5 minutes or more for alerts, and Grafana's rate-interval variable for dashboards. The 4x rule lets the result survive even if one or two scrapes get missed.

Trap 2 — Applying rate After sum

This mistake survives a long time because the result looks plausible.

# Wrong — sum the counters first, and an instance restart (a reset) goes undetected
rate(sum(http_requests_total) by (route)[5m:])

# Right — rate first, then aggregate
sum(rate(http_requests_total[5m])) by (route)

Counter-reset correction is only accurate at the level of an individual time series. When one pod restarts, that series drops to 0, but once it's already been summed, it just looks like "the total dropped a little," and doesn't get recognized as a reset. The result is a request rate that comes out lower than reality, making it look like traffic drops every time right after a deploy.

Trap 3 — Graphing a Counter As-Is

# Produces a line that just keeps rising to the upper right. No information at all
http_requests_total

# Per-second rate of increase — this is the value you actually wanted to see
sum(rate(http_requests_total[5m])) by (route)

# Total increase over a specific window — good to use in an alert message
sum(increase(http_requests_total{status_class="5xx"}[1h])) by (route)

increase is rate multiplied by the window length. So it produces a value that isn't an integer. When 3 errors actually happened but increase returns 3.4, that's not a bug — it's a result of extrapolation. Don't use it for a calculation that needs "exactly N."

irate only looks at the last two samples. It's useful for seeing an instant reaction on a dashboard, but never use it in an alert. A single burst of noise sets it off.

Conditions Under Which histogram_quantile Silently Gives the Wrong Answer

Quantile computation has even more traps.

Trap 1 — Leaving le Out of the Aggregation

# Wrong — drop le, and the buckets get mashed together into a meaningless number
histogram_quantile(0.99, sum(rate(http_request_duration_seconds_bucket[5m])) by (route))

# Right — always keep le
histogram_quantile(0.99,
  sum(rate(http_request_duration_seconds_bucket[5m])) by (le, route)
)

This mistake returns a number instead of throwing an error. So it sits on dashboards for months.

Trap 2 — Extrapolating Outside the Bucket Boundaries

histogram_quantile linearly interpolates between buckets. If p99 sits above the last finite bucket, the function returns that last boundary value. If the real p99 is 8 seconds but the last bucket is 2.5 seconds, the result is forever 2.5 seconds.

# Diagnosis — look at the ratio between the Inf bucket and the last finite bucket
sum(rate(http_request_duration_seconds_bucket{le="+Inf"}[5m])) by (route)
-
sum(rate(http_request_duration_seconds_bucket{le="10.0"}[5m])) by (route)

If this value is greater than 0, it means there are requests beyond the last bucket, and p99 may be in an unreliable state.

Conversely, if the buckets are too sparse, interpolation error is large. If the buckets are only 0.1 and 1.0, but most requests cluster at 0.15 seconds, the p99 estimate linearly cuts between 0.1 and 1.0 and ends up far off from reality. Set buckets densely around the SLO threshold. If the target is 300ms, the buckets should include 0.2, 0.25, 0.3, 0.4, 0.5.

Trap 3 — Averaging Quantiles

# Wrong — quantiles don't support arithmetic averaging
avg(histogram_quantile(0.99, ...))

# Right — sum the bucket counters first, then compute the quantile
histogram_quantile(0.99, sum(rate(http_request_duration_seconds_bucket[5m])) by (le))

The average of per-instance p99s isn't the overall p99. This is exactly why a histogram is valuable. Bucket counters can be added together, and computing the quantile after adding them gives you an estimate over the whole distribution. Expose an application-computed p99 gauge instead, and you lose this property.

Native Histograms

Native histograms have been a stable feature since Prometheus 3.8.0. Bucket boundaries are managed automatically with an exponential schema, so you don't have to pick them yourself, and it's represented as a single time series, which cuts cardinality dramatically. Scraping still has to be turned on explicitly, though.

# prometheus.yml
global:
  scrape_interval: 15s

scrape_configs:
  - job_name: checkout-api
    # Native histogram scraping is explicit opt-in
    scrape_native_histograms: true
    static_configs:
      - targets: ['checkout-api:8000']
# Fixed buckets: the _bucket series carries the le label
histogram_quantile(0.99, sum(rate(http_request_duration_seconds_bucket[5m])) by (le, route))

# Native histogram: pass the metric itself, without le
histogram_quantile(0.99, sum(rate(http_request_duration_seconds[5m])) by (route))
ItemFixed-bucket histogramNative histogram
Time-series countBucket count times label combinations1 per label combination
Bucket designMust be chosen up front; changing it breaks continuity with the pastAutomatic, only resolution is specified
Out-of-range valuesClipped at the last boundaryBroad coverage via exponential schema
Tooling compatibilityWorks everywhereRequires scrape opt-in, unsupported by some tools
Migration difficultyBaselineRequires query and dashboard changes

It's safer to convert starting with new metrics. Change an existing metric, and at that point both past data and queries break continuity.

Recording Rules — When, and Under What Name

A recording rule moves computation from query time to evaluation time. There are four criteria for creating one.

  1. When the same expression repeats in three or more places
  2. When query execution exceeds 2 seconds
  3. When an alert rule runs a heavy expression on every evaluation
  4. When you want to aggregate a high-cardinality source and retain it long-term

Names follow the level:metric:operation convention. Colons are used only in recording rules and never in a raw metric name. Follow this convention, and you can tell what dimension is left just by looking at the name.

# rules/http.yml
groups:
  - name: http_sli
    interval: 30s
    rules:
      # Tier 1 — aggregate from the source exactly once
      - record: route:http_requests:rate5m
        expr: sum(rate(http_requests_total[5m])) by (route)

      - record: route:http_requests_errors:rate5m
        expr: sum(rate(http_requests_total{status_class="5xx"}[5m])) by (route)

      # Tier 2 — references tier 1. Doesn't rescan the source
      - record: route:http_error_ratio:rate5m
        expr: |
          route:http_requests_errors:rate5m
            /
          route:http_requests:rate5m

      - record: route:http_request_duration_seconds:p99_rate5m
        expr: |
          histogram_quantile(0.99,
            sum(rate(http_request_duration_seconds_bucket[5m])) by (le, route)
          )

      # Service-wide — a summary with the route dimension removed
      - record: service:http_error_ratio:rate5m
        expr: |
          sum(rate(http_requests_total{status_class="5xx"}[5m]))
            /
          sum(rate(http_requests_total[5m]))

The layered structure is the key part. When multiple rules reuse tier 1, scanning the source time series ends in one pass. That said, rules must never form a circular reference, and promtool doesn't catch this, so it has to be checked in review.

Calculate how many time series a recording rule creates before deploying it. With 120 routes and a rule created for each of 5 windows, that's 600 new time series. With 50 rules, that's 30,000.

Run validation in CI.

# Syntax check
promtool check rules rules/http.yml

# Unit tests — feed input series and verify expected values
promtool test rules tests/http_test.yml

# Full config check
promtool check config prometheus.yml
# tests/http_test.yml
rule_files:
  - ../rules/http.yml

evaluation_interval: 30s

tests:
  - interval: 15s
    input_series:
      - series: 'http_requests_total{route="/v1/orders", status_class="2xx"}'
        values: '0+150x40'
      - series: 'http_requests_total{route="/v1/orders", status_class="5xx"}'
        values: '0+3x40'
    promql_expr_test:
      - expr: route:http_error_ratio:rate5m
        eval_time: 8m
        exp_samples:
          - labels: 'route:http_error_ratio:rate5m{route="/v1/orders"}'
            value: 0.0196078431372549

Compute the expected value yourself ahead of time, and CI will catch it if someone later changes the meaning of a rule while "optimizing" it.

Five Questions to Ask Yourself Before Putting Something on a Dashboard

Every time you build a panel, run it through the checks below. If it doesn't pass, don't build that panel.

  1. Can you write the question this panel answers in one sentence? "CPU utilization" isn't a question. "Is this service hitting a CPU limit and causing latency" is a question.
  2. Is it decided what to do when the value gets worse? A metric you can look at but that triggers no action stays an exploratory query, not a dashboard panel.
  3. How does this value connect to user experience? GC count means nothing by itself. It becomes meaningful only placed alongside latency.
  4. Do you know the normal range? If you don't know what's normal, you can't know what's anomalous either. Decide the axis range and threshold line together.
  5. Is this query graphing a counter as-is, or averaging a quantile? Double-check the traps covered in the two sections above.

Panel order follows question order too. The top is user-facing metrics, below that is candidate causes, and the bottom is infrastructure resources. Reading top to bottom gives you the flow "is there impact, where's it from, is it a resource issue."

Closing — A Metric's Value Comes From Answering Questions, Not From Its Count

It's common for on-call to come up empty in a system with 300 metrics. On the flip side, some teams narrow down most incidents with a well-chosen 20. The difference isn't the volume collected — it's whether each metric has an explicit statement of which question it exists to answer.

There are two cheap checks you can run right now. First, pull the top 10 metrics by time-series count and write down, for each, "what question does this answer." The ones you can't answer usually make up half the cost. Second, search dashboards for every query containing histogram_quantile and check whether by (le is present. There's usually one missing it.

Further reading.