Metrics Cardinality skill for Codex
Find the label that is eating your metrics bill. Codex measures series per metric and label with the Prometheus TSDB status API and a bundled report script that flags ID-like label values, then traces each offender to the code that emits it. Fixes go to the source first (route templates, enums, removed IDs), with relabel drops, OpenTelemetry views and scrape limits as guardrails, and every dashboard using the metric is checked before a label disappears.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- metrics-cardinality Installs to ~/.agents/skills/metrics-cardinality/ for every project on this machine.
curl -fsSL codexskills.dev/cli.mjs -o codex-skills.mjsnode codex-skills.mjs install metrics-cardinality The CLI also searches, updates and removes skills. Run it with no arguments to pick from a list. Needs Node 20.12+.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- --project metrics-cardinality Installs to .agents/skills/metrics-cardinality/ in the current repository. Commit it and everyone who runs Codex there gets it.
curl -fsSLO codexskills.dev/downloads/metrics-cardinality.zipunzip -o metrics-cardinality.zip -d ~/.agents/skills/ The archive holds the skill folder with its generated agents/openai.yaml. Unzip into .agents/skills/ to scope it to a repo.
& ([scriptblock]::Create((irm codexskills.dev/install.ps1))) metrics-cardinality PowerShell 5.1 or 7. Installs to %USERPROFILE%\.agents\skills\. Add -Project for the current repo.
104-line POSIX script. No sudo, no telemetry. Read install.sh
Use it in Codex
Start a prompt with $metrics-cardinality, or pick it from /skills.
You can also just describe the task: Codex reads the description below and loads the playbook when your request matches.
What Codex reads before loading it
Finds and fixes metric cardinality explosions and their cost - measuring active series with the Prometheus TSDB status API and a bundled report script, tracing the offending labels back to code, then dropping, aggregating or relabeling them with metric_relabel_configs, OpenTelemetry views, scrape limits, recording rules or vendor controls such as Datadog Metrics without Limits. Use when Prometheus memory or Mimir/Grafana Cloud/Datadog metric bills grow, when queries time out on high-series metrics, or when reviewing new metrics for unbounded labels.
Use it when
- Prometheus, Mimir or a vendor shows rapidly growing active series or cost
- Queries on a few metrics time out or exhaust memory
- New metrics are being added and labels need review
- Pods or deploys create high series churn
Use something else when
- You are designing metrics from scratch and need naming and types: use prometheus-metrics
- The cost problem is Datadog logs and spans rather than metrics: use datadog-apm
How to check the result
Re-run the TSDB status report and see the target metric's series fall by the expected factor, and confirm every dashboard and alert using it still returns data.
The mistake it prevents
Dropping a label at scrape time that dashboards group by, or that was the only thing distinguishing two series, causing silent breakage or duplicate-sample errors.
Done well means: you know how many active series each metric and label contributes, every label has a bounded value set with a reason to exist, the worst offenders are fixed at the source, and guardrails stop the next explosion before it reaches storage or the bill.
Why it matters
Series count = product of distinct values of each label on a metric, per target. A histogram with 12 buckets, 50 routes, 5 methods, 6 status codes on 40 pods is 12 x 50 x 5 x 6 x 40 = 720,000 series for one metric. Each active series costs memory (a few KB in the Prometheus head block), index space, query time, and in hosted products, money per series or per sample.
Measure first
# server-wide view (needs network to Prometheus)
curl -s 'http://prometheus:9090/api/v1/status/tsdb?limit=30' > tsdb.json
python3 scripts/cardinality_report.py tsdb.json --max-series 50000
# one target's exposition, to see label values
curl -s http://checkout:8080/metrics > metrics.txt
python3 scripts/cardinality_report.py metrics.txt --max-series 2000
The script (standard library) ranks metrics by series, shows distinct values per label, and flags labels whose values look unbounded: UUIDs, long hex IDs, IP addresses, emails, numeric IDs in paths. It exits 1 when something is over budget or suspicious.
PromQL equivalents (expensive; run in off-peak or against a narrow job):
topk(20, count by (__name__) ({job="checkout"}))
count(count by (route) (http_requests_total{job="checkout"}))
sum(scrape_series_added) by (job) # churn: new series per scrape
prometheus_tsdb_head_series # total active series
High churn (many new series per scrape, from pod names, build IDs or short-lived
label values) is as damaging as high steady-state count; check scrape_series_added.
For Mimir or Grafana Cloud, use the cardinality management dashboards or the
/api/v1/cardinality/label_names endpoint; for Datadog, the Metrics Summary sorted by
distinct tag values.
Trace offenders to code
rg -n '"user_id"|"request_id"|"path"|"url"|"email"|"tenant"' --type-add 'src:*.{go,py,js,ts,java,kt,rb}' -t src | rg -i 'label|tag|attribute|metric'
rg -n "http_requests_total|<metric_name>" -g '!**/node_modules/**'
Common sources: raw URL path instead of route template, user or tenant ID as a label,
error message strings, full SQL as a label, pod or container IDs on application metrics
that the scraper already adds, histogram buckets multiplied by every other label, and
auto-instrumentation attributes (http.url, net.peer.port) exported as metric labels.
Fix decision table
| Finding | Fix | Where |
|---|---|---|
| Raw path / URL label | Use the route template; bucket unknown routes to other | App code |
| User, request, trace, session IDs | Remove the label; use logs or traces (exemplars) for per-request detail | App code |
| Tenant ID needed for billing or SLOs | Keep only for top-N tenants, rest as other; or move to a dedicated low-frequency metric | App code |
| Error message label | Map to a small error_type enum | App code |
| Histogram too wide | Fewer buckets, fewer labels on the histogram than on the counter, or native histograms | App code / client config |
| Label nobody queries | labeldrop at scrape, then remove in code | Scrape config |
| Whole metric unused | drop by __name__ at scrape | Scrape config |
| OTel SDK attributes on metrics | Views with an attribute allow-list | SDK or Collector |
| Only aggregated form is queried | Recording rule for the aggregate; drop the raw series if downstream allows | Prometheus / Mimir |
| Datadog custom metric tags | Metrics without Limits: keep queried tags only | Datadog |
Fix at the source when you own the code; scrape-time drops are guardrails and quick mitigations, and they still cost the target CPU to expose.
Scrape-time controls
- job_name: checkout
sample_limit: 20000 # scrape fails (target down) if exceeded: a hard guardrail
label_limit: 30
label_value_length_limit: 200
metric_relabel_configs:
- source_labels: [__name__]
regex: "go_gc_.*|process_.*_bytes_debug"
action: drop
- regex: "user_id|session_id"
action: labeldrop
- source_labels: [__name__, route]
regex: "http_requests_total;/api/v1/orders/[0-9]+"
target_label: route
replacement: "/api/v1/orders/:id"
labeldrop can make two series identical, which causes duplicate-sample errors; only
drop labels that do not distinguish otherwise-identical series, or aggregate instead.
Setting sample_limit makes the whole scrape fail when exceeded, so alert on
prometheus_target_scrapes_exceeded_sample_limit_total and size it with headroom.
OpenTelemetry views
Keep only bounded attributes on an instrument (Python SDK example):
from opentelemetry.sdk.metrics import MeterProvider
from opentelemetry.sdk.metrics.view import View
provider = MeterProvider(views=[
View(instrument_name="http.server.request.duration",
attribute_keys={"http.request.method", "http.route", "http.response.status_code"}),
])
In a Collector, the transform or attributes processor can delete attributes on
metrics before export, and the filter processor can drop whole metrics.
Guardrails
- Per-job
sample_limitandlabel_value_length_limit - Alert on total head series growth (> 20% week over week) and on churn
- Code review rule: every new metric lists its labels and expected value counts
- Lint in CI: run
cardinality_report.pyagainst the service’s/metricsin an integration test and fail on suspicious labels - Per-tenant limits in Mimir/Cortex (
max_global_series_per_user) if multi-tenant
Verification
- Re-run the TSDB status report after the change; the target metric’s series count
drops by the expected factor and
prometheus_tsdb_head_seriesfalls after head compaction (up to a few hours). - Every dashboard and alert using the metric still returns data: search them for the metric name and the dropped labels before deploying the drop.
- For hosted vendors, check the usage page a day later; billing lags.
Output
A table: metric, series before, offending labels and value counts, fix applied, series after, dashboards or alerts touched. Include the guardrails added.
Gotchas
- Dropping a label that a dashboard groups by silently breaks that panel; grep dashboards and rules first.
- Exemplars are the right place for trace IDs; labels are not.
- Short-lived pods multiply series through
podandinstancelabels over time even if the count at any moment looks fine; look at series created per day, not just active. - Kubernetes
kube-state-metricswith all labels and annotations allow-listed is a frequent hidden explosion; restrict--metric-labels-allowlist.
What the zip contains
Codex reads bundled files only when a step points to them. Every one is shown here, so you can review it before installing.
agents/openai.yaml
Gives the Codex app a display name, a one-line description and a starter prompt for this skill.
interface:
display_name: "Metrics Cardinality"
short_description: "Find the label that is eating your metrics bill"
default_prompt: "$metrics-cardinality Prometheus memory doubled this month, find which metrics and labels caused it" scripts/cardinality_report.py
#!/usr/bin/env python3
"""Report metric cardinality from a Prometheus exposition dump or a TSDB status JSON.
Usage:
curl -s localhost:8080/metrics > metrics.txt
python3 scripts/cardinality_report.py metrics.txt [--top 15] [--max-series 1000]
curl -s 'http://prometheus:9090/api/v1/status/tsdb?limit=50' > tsdb.json
python3 scripts/cardinality_report.py tsdb.json
For exposition text it prints series per metric, distinct values per label per metric,
and labels whose values look unbounded (IDs, UUIDs, IPs, long paths, emails).
Exits 1 if any metric exceeds --max-series or a suspicious label is found, else 0.
Standard library only.
"""
import argparse
import json
import re
import sys
from collections import defaultdict
LINE = re.compile(r'^([a-zA-Z_:][a-zA-Z0-9_:]*)(\{(.*)\})?\s+\S+')
LABEL = re.compile(r'([a-zA-Z_][a-zA-Z0-9_]*)="((?:[^"\\]|\\.)*)"')
SUSPICIOUS = [
("uuid", re.compile(r"[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}", re.I)),
("long hex id", re.compile(r"\b[0-9a-f]{16,}\b", re.I)),
("ip address", re.compile(r"\b\d{1,3}(\.\d{1,3}){3}\b")),
("email", re.compile(r"[^@\s]+@[^@\s]+\.[a-z]{2,}", re.I)),
("numeric id in path", re.compile(r"/\d{2,}(/|$)")),
]
SUFFIXES = ("_bucket", "_sum", "_count", "_created", "_total")
def family(name, types):
for s in SUFFIXES:
if name.endswith(s) and name[: -len(s)] in types:
return name[: -len(s)]
return name
def report_exposition(text, top, max_series):
types = {}
series = defaultdict(int)
values = defaultdict(lambda: defaultdict(set))
for line in text.splitlines():
if line.startswith("# TYPE "):
parts = line.split()
if len(parts) >= 4:
types[parts[2]] = parts[3]
continue
if not line or line.startswith("#"):
continue
m = LINE.match(line)
if not m:
continue
fam = family(m.group(1), types)
series[fam] += 1
for k, v in LABEL.findall(m.group(3) or ""):
if k != "le" and k != "quantile":
values[fam][k].add(v)
total = sum(series.values())
print(f"total series in dump: {total}, metric families: {len(series)}\n")
print(f"{'series':>8} {'type':10} metric (labels: distinct values)")
problems = 0
for fam, n in sorted(series.items(), key=lambda kv: -kv[1])[:top]:
labels = ", ".join(f"{k}:{len(v)}" for k, v in sorted(values[fam].items(), key=lambda kv: -len(kv[1])))
flag = " <-- over budget" if n > max_series else ""
print(f"{n:>8} {types.get(fam, '?'):10} {fam} ({labels}){flag}")
if n > max_series:
problems += 1
findings = []
for fam, labels in values.items():
for k, vals in labels.items():
hits, sample = set(), None
for v in sorted(vals):
for name, rx in SUSPICIOUS:
if rx.search(v):
hits.add(name)
sample = sample or v[:60]
if hits:
findings.append(f"{fam}{{{k}}}: {len(vals)} values, looks like {', '.join(sorted(hits))} (e.g. {sample!r})")
if findings:
print("\nlabels with unbounded-looking values:")
for f in sorted(findings):
print(f" {f}")
problems += len(findings)
print("\nnote: one dump is one target; multiply by replicas and other label sets for the server-wide count.")
return 1 if problems else 0
def report_tsdb(data, top, max_series):
d = data.get("data", data)
head = d.get("headStats", {})
if head:
print(f"head series: {head.get('numSeries')}, chunks: {head.get('chunkCount')}\n")
problems = 0
sections = [
("seriesCountByMetricName", "series by metric"),
("labelValueCountByLabelName", "distinct values by label name"),
("seriesCountByLabelValuePair", "series by label=value pair"),
("memoryInBytesByLabelName", "memory bytes by label name"),
]
for key, title in sections:
rows = d.get(key) or []
if not rows:
continue
print(title)
for r in rows[:top]:
flag = ""
if key == "seriesCountByMetricName" and r.get("value", 0) > max_series:
flag = " <-- over budget"
problems += 1
print(f" {r.get('value'):>10} {r.get('name')}{flag}")
print()
return 1 if problems else 0
def main():
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("path", help="exposition text or TSDB status JSON; - for stdin")
ap.add_argument("--top", type=int, default=15)
ap.add_argument("--max-series", type=int, default=1000, help="per-metric budget for the over-budget flag")
a = ap.parse_args()
try:
text = sys.stdin.read() if a.path == "-" else open(a.path, encoding="utf-8", errors="replace").read()
except OSError as exc:
print(f"error: {exc}", file=sys.stderr)
return 2
if text.lstrip().startswith("{"):
try:
return report_tsdb(json.loads(text), a.top, a.max_series)
except json.JSONDecodeError as exc:
print(f"error: invalid JSON: {exc}", file=sys.stderr)
return 2
return report_exposition(text, a.top, a.max_series)
if __name__ == "__main__":
sys.exit(main())
About this skill
Written and maintained by the Codex Skills editors to the same editorial standard as every skill here: a selective description, a real workflow, known pitfalls and a way to verify the work. First published , last updated . MIT licensed. Something wrong or out of date? Tell us.