How a Debug Label on One Histogram Grew Prometheus to 3.9 Million Series and Silenced Our Pager
← Back
September 9, 2026DevOps9 min read

How a Debug Label on One Histogram Grew Prometheus to 3.9 Million Series and Silenced Our Pager

Published September 9, 20269 min read

11:14 UTC. A message in #support-escalations, not PagerDuty: "checkout is hanging for a full minute for at least six customers, is something down?" On-call checked their phone. No pages. No missed calls. Grafana's checkout dashboard was open in a browser tab from an hour earlier, every panel on it now said "No data."


the setup

checkout-api emits a histogram, checkout_latency_seconds, scraped by a single Prometheus replica running in the same Kubernetes cluster. No Thanos, no Cortex, one StatefulSet with a 6Gi memory limit that had been enough for two years of steady growth. Alertmanager ran as a sidecar reading rules evaluated by that same Prometheus instance. It was the one thing standing between a broken checkout flow and a page, and it was also a single point of failure nobody had written down as one.

Five hours earlier, a support ticket had come in about one customer seeing consistently slow checkouts. An engineer added a label to help narrow it down.

checkout-api instrumentation, as shipped at 06:20 UTC
checkout_latency = Histogram(
    "checkout_latency_seconds",
    "Checkout request latency",
    ["customer_id", "payment_method"],  # customer_id added to debug ticket SUP-4471
    buckets=[0.1, 0.25, 0.5, 1, 2.5, 5, 10],
)

It was meant to ship for an hour, get a graph filtered to one customer, then get reverted. It didn't get reverted. It went out with the next deploy to every pod, which meant every customer who hit checkout started contributing their own label value, multiplied by seven latency buckets and two payment methods, to a metric that used to have a few hundred series total.


the scramble

First theory: Grafana itself was broken, a bad panel query or a stuck browser tab. A fresh incognito load of the dashboard showed the same blanks. Grafana's own health endpoint returned 200. The dashboard wasn't broken. It had nothing to show.

Second theory: a network problem between Grafana and its Prometheus datasource. On-call exec'd into the Grafana pod and curled Prometheus directly.

from inside the grafana pod, 11:19 UTC
$ curl -sS http://prometheus.monitoring.svc:9090/-/healthy
curl: (7) Failed to connect to prometheus.monitoring.svc port 9090: Connection refused

Not a network problem. Prometheus wasn't listening on that port at all, which meant it probably wasn't running.


the hunt

kubectl get pods -n monitoring showed the answer nobody wanted: prometheus-0 0/1 CrashLoopBackOff 14 (78s ago). Fourteen restarts, roughly ninety seconds apart, which matched almost exactly how long the pod had been unreachable, and probably how long the pager had been silent.

kubectl logs prometheus-0 --previous
level=info msg="Starting WAL replay" ...
level=info msg="WAL segment loaded" segment=00001862 ...
level=info msg="WAL segment loaded" segment=00001863 ...
(no further output, container terminated)
kubectl describe pod prometheus-0
Last State: Terminated
  Reason: OOMKilled
  Exit Code: 137

Dead end one: WAL corruption from a previous unclean shutdown. The team ran promtool tsdb analyze against a copy of the volume. The WAL parsed fine. Whatever was killing the process was happening during normal replay, not because the data was broken.

Dead end two: someone had quietly lowered the memory limit in a recent Helm values change. git log -p on the monitoring chart showed the limit unchanged for four months at 6Gi. The ceiling hadn't moved. Something underneath it had grown.

One thing survived the crash: checkout-api also remote-writes a metrics subset to a managed Prometheus workspace for long-term retention, and that path doesn't need the local replica to stay alive, only to have already shipped its last few samples. Querying prometheus_tsdb_head_series against the managed workspace, right up to the last successful remote-write before the crash loop began, told the real story.

prometheus_tsdb_head_series{instance="prometheus-0"}, remote-write history
06:00  14,220
07:00  61,880
08:00  340,510
09:00  1,116,900
10:00  2,703,440
10:47  3,912,600   (last sample before remote-write itself stopped)

A metric that starts at fourteen thousand series and passes three point nine million in under five hours is not organic traffic growth. The timestamp lined up with checkout-api's deploy history, right at the 06:20 UTC release, and a two-line diff in its instrumentation file confirmed it: the customer_id label, still live, still collecting a brand new series for every distinct customer who checked out for the first time that morning.


the find

Root cause: an unbounded label on an existing histogram turned a low-cardinality metric into one with a new series for every customer, multiplied across every bucket and payment method combination. Each series costs Prometheus a few kilobytes of resident memory in the head block, so going from roughly three hundred series to nearly four million pushed the process well past its 6Gi limit. Kubernetes OOM-killed it, and every restart had to replay a WAL that kept the full history of that same exploding series count, so replay itself started consuming enough memory to trigger another OOM before it finished. There was no way out of the loop without intervention, the crash was recreating the exact condition that caused it every time.

The part that turned a metrics problem into a full incident: Alertmanager evaluated its rules through that same dead Prometheus instance, so no alert fired for Prometheus being down, and no alert fired for the real checkout latency spike that started independently around 10:50 UTC. Both were invisible from inside the same monitoring stack that was supposed to catch them.


the fix

Immediate mitigation, before any app code could redeploy: drop the offending label at scrape time so Prometheus could start and stay up.

prometheus.yml, scrape config patch
metric_relabel_configs:
  - source_labels: [__name__]
    regex: 'checkout_latency_seconds.*'
    action: labeldrop
    regex: 'customer_id'

Then the actual application fix, removing the label at the source instead of relying on relabeling to keep hiding it.

checkout-api instrumentation, after
checkout_latency = Histogram(
    "checkout_latency_seconds",
    "Checkout request latency",
    ["payment_method"],
    buckets=[0.1, 0.25, 0.5, 1, 2.5, 5, 10],
)

# Per-customer debugging goes through structured logs and trace attributes,
# never through a metric label. Traces are cardinality-safe by design.

A per-target sample limit went in as a blast-radius control, so the next unbounded label fails loudly on one scrape instead of taking down the whole process.

prometheus.yml, scrape config
scrape_configs:
  - job_name: checkout-api
    sample_limit: 20000
    static_configs:
      - targets: ["checkout-api:9100"]

And a dead man's switch that doesn't route through Prometheus at all: a cron job pings an external heartbeat endpoint every minute, and a missed heartbeat pages on-call directly. The one thing that failed here was the assumption that Prometheus monitoring its own health was enough to know when Prometheus itself had stopped working.


the aftermath

3.9M Peak time series, up from roughly 14,000 five hours earlier
14 CrashLoopBackOff restarts before mitigation
52 min From first dead pager to a human noticing, via a support Slack message
2 Independent incidents running blind at once: Prometheus down, checkout genuinely slow

Nothing about this required a traffic spike or a bad deploy to checkout-api's actual request handling. A label meant to live for an hour lived for five, and the metric it sat on multiplied it across every bucket it already had. The monitoring stack didn't fail loudly because there was nothing left inside it to raise the alarm.

  • A label on an existing metric is not a small change. Any label with unbounded cardinality (user IDs, request IDs, email addresses) multiplies series count by every distinct value it will ever see, and histograms multiply that again by their bucket count.
  • A single Prometheus replica with no independent liveness check is a single point of failure for every alert that depends on it, including the alert that would tell you it's down.
  • A crash loop caused by resource growth during startup, like WAL replay, can be worse than the steady-state condition that caused it. The failure recreates itself every restart.
  • Debugging data scoped to one entity belongs in logs or trace attributes, not metric labels. Metrics are for aggregates; anything with as many distinct values as your customer base isn't one.

The sample limit of 20,000 isn't a permanent number, it's a tripwire sized well above legitimate cardinality today so the next unbounded label gets caught on one target instead of taking down the process it's scraped by.

Share this
← All Posts9 min read