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.

  • Observability & SRE
  • $metrics-cardinality
  • 155 lines
  • 3 files
  • ~1,700 tokens
  • MIT
  • Updated

Install

curl -fsSL codexskills.dev/install.sh \  | sh -s -- metrics-cardinality

Installs to ~/.agents/skills/metrics-cardinality/ for every project on this machine.

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.

~/.agents/skills/metrics-cardinality/SKILL.md

--- name: metrics-cardinality description: Finds and fixes metric cardinality explosions and their cost - measuring active series with the Prometheus TSDB status API and a bundled report… license: MIT ---

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

FindingFixWhere
Raw path / URL labelUse the route template; bucket unknown routes to otherApp code
User, request, trace, session IDsRemove the label; use logs or traces (exemplars) for per-request detailApp code
Tenant ID needed for billing or SLOsKeep only for top-N tenants, rest as other; or move to a dedicated low-frequency metricApp code
Error message labelMap to a small error_type enumApp code
Histogram too wideFewer buckets, fewer labels on the histogram than on the counter, or native histogramsApp code / client config
Label nobody querieslabeldrop at scrape, then remove in codeScrape config
Whole metric unuseddrop by __name__ at scrapeScrape config
OTel SDK attributes on metricsViews with an attribute allow-listSDK or Collector
Only aggregated form is queriedRecording rule for the aggregate; drop the raw series if downstream allowsPrometheus / Mimir
Datadog custom metric tagsMetrics without Limits: keep queried tags onlyDatadog

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_limit and label_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.py against the service’s /metrics in 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_series falls 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 pod and instance labels over time even if the count at any moment looks fine; look at series created per day, not just active.
  • Kubernetes kube-state-metrics with 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.yamlgenerated from this page

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.py141 lines, 5.3 KB
#!/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.

esc
New

Introducing Codex Skills Pro

79 professional skills that take Codex through complete engagements, each with its own scripts, references and a report that verifies the result.

All-AccessOne-time payment, no subscription
$49
Get All-AccessOr browse individual skills from $2.99 →

Secure checkout by Creem