“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
prometheus_tsdb_head_seriesfinding the worst offendermetric_relabel_configssample_limit00 It starts
Reproduce this incident in your lab, then work the case alongside the timeline below. Try each query yourself before reading its result.
01 The investigation
- 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 minutestrue restarts=4In the lab, set `mem_limit: 300m` on prometheus to see the OOM kills quickly. In production this happens at gigabytes instead. - 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· Prometheusprometheus_tsdb_head_seriesresult{instance="localhost:9090", job="prometheus"} 138412head seriesseries - 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· Prometheustopk(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 - 09:25
QUERY
Which label makes it explode?
Count distinct values per label for that metric.
user_idhas one value per user who ever viewed a cart, which is unbounded.PromQL· Prometheuscount(count by (user_id) (shoplite_cart_views_total))result{} 136304 - 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_configsrun 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 - 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
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 logs02Switch 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
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"] }]02Alert on series growth and on scrapes hitting the limit
derivgives the per-second slope of a gauge.scrape_samples_post_metric_relabelingshows each target's sample count, andprometheus_target_scrapes_exceeded_sample_limit_totalcounts 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.
Which scrape target currently exposes the most samples?
Write a query that counts the number of series per job.
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
What is metric cardinality and why does it matter?
How would you protect a Prometheus server from a high-cardinality metric shipped by an app team?