Command Palette

Search for a command to run...

Hectal
Case 1.2·Metrics in AngerSEV1

“Prometheus is OOM-killed every 20 minutes and every dashboard is blank. Nothing in ShopLite changed... except one new metric.”

case name: One label took down Prometheus

Service
prometheus
Impact
Monitoring blind for 1 h 50 min; alerts could not fire during that time
Detected by
Grafana panels showing 'No data'; Prometheus container restart count
Time to resolve
1 h 50 min

Skills you'll use on this case

cardinality and seriesprometheus_tsdb_head_seriesfinding the worst offendermetric_relabel_configssample_limit

00 It starts

Reproduce this incident in your lab, then work the case alongside the timeline below. Try each query yourself before reading its result.

terminal
$ curl -s -X POST localhost:8080/admin/chaos -H 'content-type: application/json' -d '{"scenario":"cardinality"}'
── lab output ──
{"active":["cardinality"]}
Every product view now increments shoplite_cart_views_total{user_id=<random 1 of 1,000,000>}. Leave it on for 10 minutes.

01 The investigation

  1. 09:12

    ALERT

    Grafana: 'No data' everywhere; Prometheus container restarting

    terminal
    $ docker compose ps prometheus --format '{{.Status}}'
    docker inspect $(docker compose ps -q prometheus) --format '{{.State.OOMKilled}} restarts={{.RestartCount}}'
    ── expected output ──
    Up 2 minutes
    true restarts=4
    In the lab, set `mem_limit: 300m` on prometheus to see the OOM kills quickly. In production this happens at gigabytes instead.
  2. 09:18

    QUERY

    How many series is Prometheus holding in memory?

    Every unique combination of metric name + label values is a SERIES, and every active series lives in memory (the 'head block'). Prometheus exposes its own count.

    PromQL· Prometheus
    prometheus_tsdb_head_series
    result
    {instance="localhost:9090", job="prometheus"}  138412
    head seriesseries
    0.007958715917408:3009:20
  3. 09:22

    QUERY

    Which metric owns them?

    count by (__name__) counts series per metric name. This query touches every series, so it's expensive on a big Prometheus; run it on a small time window, or use the TSDB status page (/tsdb-status), which gives the same answer from precomputed stats.

    PromQL· Prometheus
    topk(5, count by (__name__) ({__name__=~".+"}))
    result
    {__name__="shoplite_cart_views_total"}                    136304
    {__name__="http_request_duration_seconds_bucket"}             396
    {__name__="nodejs_gc_duration_seconds_bucket"}                 84
    {__name__="prometheus_http_request_duration_seconds_bucket"}   72
    {__name__="process_cpu_seconds_total"}                          3
  4. 09:25

    QUERY

    Which label makes it explode?

    Count distinct values per label for that metric. user_id has one value per user who ever viewed a cart, which is unbounded.

    PromQL· Prometheus
    count(count by (user_id) (shoplite_cart_views_total))
    result
    {}  136304
  5. 09:31

    ACTION

    Stop the bleeding at the scrape: drop the metric before it's stored

    The app can't be redeployed instantly, but Prometheus can refuse the metric right now. metric_relabel_configs run after the scrape and before ingestion.

    obs-lab/prometheus/prometheus.ymladd to fileyaml
      - job_name: shoplite
        static_configs: [{ targets: ["shoplite:8080"] }]
        metric_relabel_configs:
          - source_labels: [__name__]
            regex: shoplite_cart_views_total
            action: drop
  6. 11:02

    RESOLVED

    Series count flat at ~1,900; memory stable; old series age out of the head

    Series already written stay on disk until retention removes them, but they're no longer in memory once they stop receiving samples (after ~1–2 h, when the head compacts).

Root cause

A new counter used user_id as a label. Each distinct value creates a new time series, and Prometheus holds every active series in memory with its own index entries and chunks. With an unbounded label, series grow with the number of users until Prometheus runs out of memory, taking down alerting for EVERY service, not just ShopLite.

02 The concepts behind it

Cardinality = product of label values

A metric's series count is the number of distinct label combinations that actually occur. http_request_duration_seconds_bucket with 3 methods × 4 routes × 5 statuses × 11 buckets × 2 instances could reach thousands of series, and that's fine. Add user_id (1,000,000 values) and the same metric could reach billions.

Each series costs memory (roughly a few KB in the head), index space, and query time. Good labels have a small, bounded set of values: route templates (/users/:id, not /users/42), status codes, regions, instance names. Unbounded values like user IDs, emails, full URLs, request IDs, timestamps, and error messages belong in LOGS or TRACES, not metric labels.

The hidden cardinality traps

Raw URL paths as a label (/products/1, /products/2, ...), which is why ShopLite records req.route.path, the TEMPLATE. Error messages as a label (every unique message is a series). Kubernetes pod names that change on every deploy (bounded at any moment, but churn creates new series constantly). Histograms multiply everything by the bucket count.

Defence in depth

Code review: labels must have bounded values. Scrape time: metric_relabel_configs to drop or rewrite offending metrics and labels. Limits: sample_limit fails a scrape that returns too many samples instead of letting it poison the TSDB. The scrape fails loudly (up = 0) instead of Prometheus dying quietly. Alerting: watch head series growth.

03 The fix

  1. 01Remove the label in code

    If the business needs per-user cart views, that's an analytics event (a log line or a row in a warehouse), not a metric. The metric keeps only a bounded dimension, if any.

    obs-lab/shoplite/server.jsadd to filejs
    const cartViews = new client.Counter({ name: "shoplite_cart_views_total", help: "Cart views" });
    // ...
      cartViews.inc();                        // no user_id label
      req.log.info({ user_id }, "cart viewed"); // per-user detail goes to logs
  2. 02Switch the scenario off and reload

    terminal
    $ curl -s -X POST localhost:8080/admin/chaos -H 'content-type: application/json' -d '{"scenario":"cardinality","enabled":false}'
    curl -s -X POST localhost:9090/-/reload
    ── expected output ──
    {"active":[]}

04 Make sure it never surprises you again

  1. 01sample_limit on every scrape job

    ShopLite normally exposes a few hundred samples. A limit of 5,000 leaves headroom for growth while making an explosion fail the scrape, visibly and without taking Prometheus down.

    obs-lab/prometheus/prometheus.ymladd to fileyaml
      - job_name: shoplite
        sample_limit: 5000
        static_configs: [{ targets: ["shoplite:8080"] }]
  2. 02Alert on series growth and on scrapes hitting the limit

    deriv gives the per-second slope of a gauge. scrape_samples_post_metric_relabeling shows each target's sample count, and prometheus_target_scrapes_exceeded_sample_limit_total counts scrapes rejected by the limit.

    obs-lab/prometheus/alerts.ymladd to fileyaml
          - alert: PrometheusSeriesGrowingFast
            expr: deriv(prometheus_tsdb_head_series[15m]) * 3600 > 20000
            for: 10m
            labels: { severity: warning, service: prometheus }
            annotations:
              summary: "Head series growing by {{ $value | humanize }}/hour — check for a new high-cardinality label"
          - alert: ScrapeSampleLimitHit
            expr: increase(prometheus_target_scrapes_exceeded_sample_limit_total[10m]) > 0
            labels: { severity: warning, service: prometheus }
            annotations:
              summary: "A target exceeded sample_limit and its scrape was rejected"

05 Your turn: write the query

Run each one against your lab before revealing the answer. Share your own version in the comments; there's usually more than one correct query.

01

Which scrape target currently exposes the most samples?

02

Write a query that counts the number of series per job.

03

A developer wants to add status_code, route, and customer_tier (free/pro/enterprise) labels to a new histogram with 12 buckets. Estimate the worst-case series count per instance for 8 routes and 6 status codes.

06 Interview questions from this case

01

What is metric cardinality and why does it matter?

02

How would you protect a Prometheus server from a high-cardinality metric shipped by an app team?

0/4 · 0%