Tools

Metric cardinality calculator

/tools/metric-cardinality/

List a metric's labels and how many values each takes, pick where it is stored, and see how many time series it really writes, which change saves the most, and what that backend bills it in.

How it works

Metric cardinality calculator#

A metric is not one time series. It is one per combination of its label values, times however many series its type writes for each combination, times every process that exposes it. Each factor is easy; the product is the number people get wrong, usually by an order of magnitude, and it is the number a Prometheus server’s memory and a vendor’s bill are both counted in.

The product of the label values is the same wherever the metric is stored. What one label set writes, whether each process gets its own copy, the limits worth knowing and the unit of the bill are not, so the first input is Where the metric is stored: self-hosted Prometheus, the three managed Prometheus services, or the custom metrics of CloudWatch, Azure Monitor and Google Cloud Monitoring. The provider table below quotes the rule behind each one.

How to use it: an example#

Leave Where the metric is stored at Prometheus, self-hosted. A Go service exposes one request-duration histogram with three labels. Its routes, methods and statuses take 12, 3 and 6 values, but most routes only ever see one or two methods and a few statuses, so about a quarter of the 216 combinations actually occur. It runs on 20 pods. Choose Go client_golang under Client library, which fills in 11 buckets and no _created, and enter:

text
route 12
method 3
status 6

with Targets 20 and Combinations that occur 25%, then press Run. The answer starts with the number and the multiplication that makes it:

text
15,120 series
12 route x 3 method x 6 status values x 25% that occur x 14 series per label set x 20 targets

Then each change on its own, fewest series first, drawn as bars against the 15,120 now and listed in a table:

ChangeSeries afterSaves
Native histogram1,08014,040 (93%)
Without route1,40013,720 (91%)
Without status2,52012,600 (83%)
Without method5,04010,080 (67%)

and a quieter row of what it costs to keep:

Label setsSamplesDisk
54, where Prometheus’s guide aims for fewer than 101,008 a second1.3 GB to 2.6 GB

In plain words: one histogram on this service is fifteen thousand series, and nearly all of that is two multipliers. Each label set costs 14 series because the histogram writes one per bucket (11 buckets, le="+Inf", _sum and _count, which the method notes spell out), and each of the 20 pods writes its own copy. Switching to a native histogram or dropping route each takes away about nine tenths of it. A row like “Without route” counts dropping the label; grouping its values into a few leaves something between that and today.

Choose Counter instead and the same labels are 1,080 series, which is the cost of the buckets made visible.

The same metric on a managed Prometheus#

Now choose Amazon Managed Service for Prometheus and set Scrape or write interval to 30 s. This is the sample Same, Amazon Prometheus. The series are the same 15,120, because the service stores what the client writes, and so are the changes. What moves is the quiet row, which now counts what Amazon bills:

Label setsSamples ingested
54, where Prometheus’s guide aims for fewer than 101.31 billion a month

That is 15,120 series x 2,592,000 seconds in a 30-day month / 30 s. Type your price per 10 million samples into Your price per billing unit and a Bill appears beside it. The method notes add the workspace’s default quota of 50,000,000 active series, shared by every metric there, so it is noted rather than flagged.

Choose Google Cloud Managed Service for Prometheus instead and the answer is 1,080 series, because Google stores each classic histogram as one distribution series. The samples don’t fall, though: Google bills each scrape of a distribution as 2 samples plus 1 per bucket with a count in it, 12 buckets here, so the answer is “Up to 1.31 billion a month”, the same as Amazon’s at most.

A CloudWatch custom metric with dimensions#

An API on Lambda emits a latency metric through the Embedded Metric Format, with dimensions operation (6 values), status (3) and customer (200), and about half of the combinations occur. Twelve instances publish it. Choose Amazon CloudWatch custom metrics, including EMF, or press the sample CloudWatch EMF, per customer:

text
1,800 custom metrics
6 operation x 3 status x 200 customer values x 50% that occur x 1 metric per label set
ChangeMetrics afterSaves
Without customer91,791 (over 99%)
Without operation3001,500 (83%)
Without status6001,200 (67%)

and Billed as 1,800 metric-months. Two factors from the Prometheus answer are gone. A histogram is one metric and the twelve instances are not a factor, because CloudWatch treats each unique combination of dimensions as one metric, whatever it holds and whoever publishes it. The method notes say so, and say to list a host as a label if each instance adds one. The customer dimension is the bill: dropping it leaves 9 metrics.

The inputs#

  • Where the metric is stored, one of seven backends, described in the table below. The default is self-hosted Prometheus.
  • Labels and their values: one label (or dimension) per line, its name and how many values it takes (route 12, route=12 and route: 12 all work). Empty is a metric with no labels. Prometheus’s naming rule is checked only on the Prometheus backends.
  • Metric type. A summary’s quantile count is part of the type: Python’s prometheus_client writes summaries with no quantiles, and three (0.5, 0.9, 0.99) is a typical configuration elsewhere.
  • Client library fills in the bucket count and whether _created is written with that library’s defaults: Python’s 14 buckets and _created, the same with PROMETHEUS_DISABLE_CREATED_SERIES, or Go’s 11 buckets. Change either afterwards and it shows Custom.
  • Targets, Combinations that occur, Scrape or write interval, and an optional Your price per billing unit. The price is per whatever the backend bills, which the Bill note names: 1,000 active series a month for self-hosted (or a vendor that bills series), 10 million samples on Amazon’s or Azure’s managed Prometheus, 1 million samples on Google’s, a metric-month on CloudWatch, a MiB on Cloud Monitoring. The tool ships no price, because prices change and differ by region and tier.

Retention is not an input: the disk figure is for Prometheus’s default 15 days. Double it for 30.

What it computes#

  • Label sets: the product of each label’s value count, times the share of combinations that actually occur. Prometheus calls this the metric’s cardinality, and its instrumentation guide aims for under 10 and says to investigate anything over 100.
  • Series per label set, by type:
    • classic histogram: one per bucket, plus le="+Inf", _sum and _count;
    • summary: one per quantile, plus _sum and _count;
    • counter and gauge: one;
    • native histogram: one, with its buckets carried inside each sample;
    • plus _created for counters, histograms and summaries when the client writes it. prometheus_client for Python does by default, which is why its default histogram is 18 series, not 17.
  • Targets: every scraped process gets its own instance label, so each one multiplies the total. On Cloud Monitoring each writes to its own monitored resource, which multiplies it too. On CloudWatch and Azure custom metrics they share a series unless a label tells them apart, so targets are left out of the product.
  • Samples and disk: one sample per series per scrape. Disk uses the 1 to 2 bytes per sample the Prometheus storage documentation gives, over 15 days.
  • Billing, per backend: active series (self-hosted), samples ingested a month (the managed Prometheus services), metric-months (CloudWatch), MiB ingested a month (Cloud Monitoring), nothing yet (Azure custom metrics, free in preview). A month is 30 days. Bill appears only if you enter your price for that unit.
  • Limits: a provider’s limit is flagged in red only when this metric alone breaks it; otherwise the method notes name it. Most are shared by every metric in a workspace or subscription, so a metric under one can still help push the total over.

The answer comes in that order: the series count with its multiplication; each change the reader can make (a native histogram, dropping each label, no _created), scored by the same arithmetic with the change made and sorted by the series it leaves; the label sets, samples, disk and bill; and how the per-label-set count was reached, with how to make each change. Only the label sets carry a tone, because they are the one number Prometheus’s guide judges, and only on the Prometheus backends, because it is Prometheus’s guide.

Where the metric is stored: the rule behind each backend#

Checked on 2026-10-19 against each provider’s own documentation and pricing pages. Only what those pages state is modelled; where they are silent, the method notes say so.

BackendOne label set writesBilled per
Prometheus, self-hostedBy type1,000 active series, at a vendor’s price
Amazon Managed PrometheusBy type10 million samples
Azure managed PrometheusBy type10 million samples
Google Managed PrometheusBy type; a histogram is 11 million samples
CloudWatch custom metrics1metric-month
Azure custom metrics1free in preview
Cloud Monitoring custom metrics1; a summary by its partsMiB ingested

Targets multiply on every backend except CloudWatch and Azure custom metrics. The exact rules, quoted:

  • Prometheus, self-hosted. The instrumentation guide’s under 10 label sets, and investigate anything over 100 (instrumentation); 1 to 2 bytes a sample on disk (storage).
  • Amazon Managed Service for Prometheus. “Active series per workspace: 50,000,000”, adjustable (quotas). “Amazon Managed Service for Prometheus counts each metric sample ingested”; for native histograms “each populated bucket (a bucket with non-zero observations) counts as 0.25 of a metric sample” (pricing). Storage and query samples are billed separately and not sized here.
  • Azure Monitor managed service for Prometheus. Per workspace, “Active time series with metrics that have been reported in the last ~12 hours: 1,000,000” and “Events per minute ingested: 1,000,000”, both raisable on request (limits). Billed on “Metric samples ingested”, per 10 million; query samples separately (pricing).
  • Google Cloud Managed Service for Prometheus. “Prometheus histogram metrics are converted to the analogous Cloud Monitoring Distribution type” (exemplars). “Active time series from Prometheus per monitored resource: 1,000,000” (quotas); “a minimum scrape interval of 5 seconds” (overview). Samples are counted as “1 for each point” of a scalar, and for a fixed-bucket distribution “2 for each point written to a time series, plus 1 for each histogram bucket that has a non-zero count”; an exponential one is 2 plus 1/8 per non-zero bucket (pricing).
  • Amazon CloudWatch custom metrics, including EMF. “CloudWatch treats each unique combination of dimensions as a separate metric, even if the metrics have the same metric name”, and “You can assign up to 30 dimensions to a metric” (concepts). An EMF DimensionSet “MUST NOT contain more than 30 dimension keys”, and EMF “will by design create a custom metric corresponding to each unique dimension combination” (EMF). “All custom metrics … are prorated by the hour”; PutMetricData requests, and EMF’s log ingestion, are billed on top and not sized here (pricing).
  • Azure Monitor custom metrics. “An active time series is any unique combination of metric, dimension key, or dimension value”; “Total active time series in a subscription per region: 50,000”; “Dimension keys per metric: 10”; ingested “at a fixed 60-second interval”; “Native custom metrics are also free during the preview”, and they “won’t be made generally available” (custom metrics).
  • Google Cloud Monitoring custom metrics. “Labels per custom, external, and workload metric descriptor: 30”; “Active time series from custom metrics per monitored resource: 200,000”; points “at least 5 seconds apart”; “Histogram buckets per custom distribution metric: 200” (quotas). Billed on bytes: “8 bytes for each data point” of a scalar and “80 bytes for each data point” of a distribution, with the first 150 MiB a month free (pricing).

What the tool does not claim, because those pages don’t say:

  • Google Managed Prometheus and _created. Whether its collector stores _created as a series is not on the pages checked, so the toggle decides, and the method notes say so.
  • Native histograms on Azure’s managed Prometheus. Its limits and pricing pages don’t mention them, so the samples are not counted for that case. On Amazon’s they depend on how many buckets have counts, which no input says, so they are not counted either.
  • Tiers and free allowances. Every provider prices in volume tiers that vary by region. The bill is your price times the count; Google’s free 150 MiB is noted, not subtracted.
  • Summaries on Cloud Monitoring custom metrics. There is no summary type there; the tool counts _sum, _count and each quantile as separate series, which is how Google maps a summary written over OTLP.

Left out: OpenTelemetry (OTLP) ingestion into these backends, such as Azure Monitor’s OpenTelemetry metrics. It is a different path from the seven above, and how each provider counts an OTLP histogram was not checked for this version.

Where it stops being true#

  • Shared limits. A workspace or subscription limit is judged against this one metric, which is only useful when it alone breaks it. The rest of your metrics count against the same limit.
  • The product is a ceiling. Most routes never return most status codes. Set “Combinations that occur” if you know the share, or read the answer as the worst case.
  • Series churn is not counted. A label whose values change (a pod name, a build ID) leaves old series behind for the retention period, and those count against memory until they go stale.
  • Native histograms cost one series but a larger sample, so the disk estimate understates them.
  • Created timestamps: newer Prometheus servers can read _created as metadata rather than storing it as a series. The toggle is there for whichever your setup does.

Source and references

index.ts684 lines, TypeScript
TypeScript
/**
* Metric cardinality: how many time series one metric becomes, where it is stored, and what that backend bills.
*
* The product of label values is the same everywhere. What changes with the backend is what one label set
* writes, whether each process writing the metric gets its own series, the limits worth knowing, and the unit
* the bill is counted in. Each backend's rules are in BACKENDS below, and every one of them is a rule the
* provider's own documentation states; the README's provider table quotes and links each one.
*
* For self-hosted Prometheus:
*
* Every number is a product, so nothing here is estimated except the disk, which uses the 1 to 2 bytes per sample
* Prometheus's storage documentation gives:
*
* - label sets are the product of each label's value count, times the share of combinations that occur;
* - each label set writes a fixed number of series for its type: a classic histogram one per bucket, +Inf
* included, plus `_sum` and `_count`; a summary one per quantile plus `_sum` and `_count`; a counter one; a
* gauge or a native histogram one. Clients that write `_created` add one more to counters, histograms and
* summaries, which prometheus_client for Python does by default;
* - every scrape target attaches its own `instance` label, so every process exposing the metric multiplies it.
*
* A summary's quantile count is folded into the type: Python's prometheus_client writes summaries with no
* quantiles at all, and three (0.5, 0.9, 0.99) is a typical configuration, so those are the two options rather
* than a number input that only one type reads.
*
* The mistakes it exists for are the common ones: counting a histogram as one series, forgetting the targets, and
* treating a label's value count as additive rather than a multiplier.
*/
import type { Field, Output, Tool } from "@toolbench/sdk";
type Input = {
backend: string;
labels: string;
type: string;
client: string;
buckets: number;
created: boolean;
targets: number;
occur: number;
interval: number;
price: number;
};
/**
* Retention is not an input. The disk figure is for Prometheus's default 15 days, which the note says, and is the
* one number here that scales trivially with the reader's own retention. Cutting it left the form with one fewer
* thing to fill in on the way to the series count, which is the answer people come for.
*/
const RETENTION_DAYS = 15;
/** Billing month for the per-sample and per-byte backends. AWS's own examples use 744 hours; a month is said in the note. */
const MONTH_S = 30 * 86_400;
type Family = "prom" | "cloudwatch" | "azure-custom" | "gcm-custom";
export type Backend = {
name: string;
/** How the provider's documentation names the thing it stores. */
unit: "series" | "custom metrics";
family: Family;
/** Each target attaches its own identity: an `instance` label, or Cloud Monitoring's monitored resource. */
targetsMultiply: boolean;
/** What the price input is per, for this backend. */
priceUnit?: string;
/** A limit on series, with what it applies to. */
seriesLimit?: { n: number; per: "metric" | "target"; what: string };
/** A limit on labels per metric. Only those under the parser's own 30 can be broken. */
labelLimit?: { n: number; what: string };
/** The shortest interval the backend accepts, in seconds. */
minInterval?: number;
note: string;
};
/*
* ⚠️ Every number here is quoted in the README with its source and the date it was checked. Change one only
* from the provider's own page, and change the README in the same commit.
*
* Limits that apply to a whole workspace or subscription are judged against this one metric, so the tone is
* "this metric alone breaks it", which is the case worth flagging; the method note says they are shared.
*/
export const BACKENDS: Record<string, Backend> = {
prometheus: {
name: "Prometheus, self-hosted",
unit: "series",
family: "prom",
targetsMultiply: true,
priceUnit: "1,000 active series",
note: "Kept on your own servers.",
},
amp: {
name: "Amazon Managed Service for Prometheus",
unit: "series",
family: "prom",
targetsMultiply: true,
priceUnit: "10 million samples ingested",
seriesLimit: { n: 50_000_000, per: "metric", what: "active series per workspace, the default quota" },
note: "Billed per metric sample ingested; storage and query samples are billed separately.",
},
"azure-prom": {
name: "Azure Monitor managed service for Prometheus",
unit: "series",
family: "prom",
targetsMultiply: true,
priceUnit: "10 million samples ingested",
seriesLimit: {
n: 1_000_000,
per: "metric",
what: "active time series per Azure Monitor workspace, the default limit",
},
note: "Billed per sample ingested, retention included; query samples are billed separately.",
},
gmp: {
name: "Google Cloud Managed Service for Prometheus",
unit: "series",
family: "prom",
targetsMultiply: true,
priceUnit: "1 million samples ingested",
seriesLimit: { n: 1_000_000, per: "target", what: "active time series from Prometheus per monitored resource" },
minInterval: 5,
note: "Billed per sample ingested. A histogram is stored as one distribution series.",
},
cloudwatch: {
name: "Amazon CloudWatch custom metrics",
unit: "custom metrics",
family: "cloudwatch",
targetsMultiply: false,
priceUnit: "metric-month",
note: "Published with PutMetricData, or extracted from Embedded Metric Format logs.",
},
"azure-custom": {
name: "Azure Monitor custom metrics",
unit: "series",
family: "azure-custom",
targetsMultiply: false,
seriesLimit: { n: 50_000, per: "metric", what: "active time series in a subscription per region" },
labelLimit: { n: 10, what: "dimension keys per metric" },
note: "In preview, and Microsoft says it won't become generally available.",
},
"gcm-custom": {
name: "Google Cloud Monitoring custom metrics",
unit: "series",
family: "gcm-custom",
targetsMultiply: true,
priceUnit: "MiB ingested",
seriesLimit: { n: 200_000, per: "target", what: "active time series from custom metrics per monitored resource" },
minInterval: 5,
note: "Billed per byte ingested: 8 bytes a point for a number, 80 for a distribution.",
},
};
class InputError extends Error {
readonly input: string;
constructor(message: string, input: string) {
super(message);
this.input = input;
}
}
export type Label = { name: string; values: number };
const NAME = /^[a-zA-Z_][a-zA-Z0-9_]*$/;
/** Labels the client or the server already owns for a type, so a user label by that name collides. */
const RESERVED: Record<string, string[]> = { histogram: ["le"], summary: ["quantile"], summary3: ["quantile"] };
/** Types whose clients may write `_created`. */
const HAS_CREATED = new Set(["counter", "histogram", "summary", "summary3"]);
/**
* One label per line, or comma-separated: `route 12`, `route=12` and `route: 12` all read the same. Prometheus's
* naming rule is checked only for a Prometheus backend; CloudWatch dimensions and Azure dimensions take other names.
*/
export function parseLabels(text: string, prom = true): Label[] {
const out: Label[] = [];
const parts = text
.split(/[\n,]/)
.map((s) => s.trim())
.filter((s) => s && !s.startsWith("#"));
for (const part of parts) {
const m = /^([^\s=:]+)\s*[=:\s]\s*([0-9][0-9_]*)$/.exec(part);
if (!m) throw new InputError(`"${part}" is not a label and a count, such as "route 12".`, "labels");
const name = m[1]!;
if (prom && !NAME.test(name))
throw new InputError(
`"${name}" is not a valid label name: letters, digits and _, not starting with a digit.`,
"labels",
);
if (prom && name.startsWith("__"))
throw new InputError("Label names starting with __ are reserved for Prometheus itself.", "labels");
const values = Number(m[2]!.replace(/_/g, ""));
if (!Number.isSafeInteger(values) || values < 1) throw new InputError(`${name} needs at least 1 value.`, "labels");
if (out.some((l) => l.name === name)) throw new InputError(`${name} is listed twice.`, "labels");
out.push({ name, values });
}
if (out.length > 30) throw new InputError("At most 30 labels.", "labels");
return out;
}
/** Series one label set writes, by type. */
export function perLabelSet(
type: string,
buckets: number,
quantiles: number,
created: boolean,
): { n: number; parts: string } {
const c = created && HAS_CREATED.has(type) ? 1 : 0;
const cr = c ? " + _created" : "";
switch (type) {
case "counter":
return { n: 1 + c, parts: `_total${cr}` };
case "gauge":
return { n: 1, parts: "the value" };
case "histogram":
return { n: buckets + 1 + 2 + c, parts: `${buckets} buckets + le="+Inf" + _sum + _count${cr}` };
case "summary":
case "summary3": {
const q = type === "summary3" ? 3 : quantiles;
return { n: q + 2 + c, parts: `${q ? `${q} quantiles + ` : ""}_sum + _count${cr}` };
}
case "native":
return { n: 1, parts: "one native histogram sample, its buckets inside it" };
default:
throw new InputError(`Unknown metric type "${type}".`, "type");
}
}
/** 12345678 -> "12,345,678"; small fractions keep two digits. */
export function num(x: number): string {
if (x >= 100 || Number.isInteger(x)) return Math.round(x).toLocaleString("en-US");
return (Math.round(x * 100) / 100).toLocaleString("en-US");
}
/** Bytes in the largest unit that keeps the number at or above 1. */
export function bytes(n: number): string {
const units = ["B", "KB", "MB", "GB", "TB", "PB"];
let x = n;
let i = 0;
while (x >= 1000 && i < units.length - 1) {
x /= 1000;
i++;
}
return `${x >= 100 ? Math.round(x) : Math.round(x * 10) / 10} ${units[i]}`;
}
const money = (x: number) => (x >= 100 ? `$${Math.round(x).toLocaleString("en-US")}` : `$${x.toFixed(2)}`);
const plural = (n: number, one: string, many = `${one}s`) => `${num(n)} ${n === 1 ? one : many}`;
/** "45%", or "under 1%" for a share that would otherwise round to nothing. */
const pct = (part: number, whole: number) => {
const p = (100 * part) / whole;
if (p > 0 && p < 1) return "under 1%";
if (p > 99 && p < 100) return "over 99%";
return `${Math.round(p)}%`;
};
export function count(labels: Label[], occur: number): number {
const product = labels.reduce((p, l) => p * l.values, 1);
return Math.max(1, Math.ceil((product * occur) / 100));
}
/** 1,306,368,000 -> "1.31 billion"; below a million, the whole number. */
export function big(x: number): string {
if (x >= 1e9) return `${num(Math.round(x / 1e7) / 100)} billion`;
if (x >= 1e6) return `${num(Math.round(x / 1e4) / 100)} million`;
return num(Math.round(x));
}
export type Writes = {
/** Series one label set writes. */
n: number;
parts: string;
/** Samples one label set writes per scrape or write, where the backend bills samples. */
samples: number;
/** "" exact, "up to" when it depends on how many buckets have counts, "at least" for the same, the other way. */
bound: "" | "up to" | "at least";
/** Where samples can't be counted from the inputs: the reason. */
uncounted?: string;
/** Bytes per point, for Cloud Monitoring's byte billing. */
bytes: number;
};
/** What one label set writes, on this backend. */
export function writes(id: string, type: string, buckets: number, created: boolean): Writes {
const b = BACKENDS[id]!;
if (b.family === "cloudwatch") {
perLabelSet(type, buckets, 0, false);
return { n: 1, parts: "one metric per dimension combination, whatever the type", samples: 1, bound: "", bytes: 0 };
}
if (b.family === "azure-custom") {
perLabelSet(type, buckets, 0, false);
return {
n: 1,
parts: "one time series per combination of dimensions, whatever the type",
samples: 1,
bound: "",
bytes: 0,
};
}
if (b.family === "gcm-custom") {
if (type === "histogram" || type === "native")
return { n: 1, parts: "one distribution series, its buckets inside it", samples: 1, bound: "", bytes: 80 };
const per = perLabelSet(type, buckets, 0, false);
return { n: per.n, parts: per.parts, samples: per.n, bound: "", bytes: 8 };
}
const per = perLabelSet(type, buckets, 0, created);
if (id === "gmp" && type === "histogram") {
const c = per.n - (buckets + 3);
return {
n: 1 + c,
parts: `one distribution series, its ${buckets + 1} buckets inside it${c ? " + _created" : ""}`,
samples: buckets + 3 + c,
bound: "up to",
bytes: 0,
};
}
if (id === "gmp" && type === "native") return { ...per, samples: 2, bound: "at least", bytes: 0 };
if (id === "amp" && type === "native")
return {
...per,
samples: 0,
bound: "",
uncounted:
"Amazon meters a native histogram at 0.25 of a sample per bucket with counts in it, which depends on your data.",
bytes: 0,
};
if (id === "azure-prom" && type === "native")
return {
...per,
samples: 0,
bound: "",
uncounted: "Azure's pricing page doesn't say how a native histogram is counted.",
bytes: 0,
};
return { ...per, samples: per.n, bound: "", bytes: 0 };
}
export default {
run(input: Input) {
try {
const id = String(input.backend ?? "prometheus");
const backend = BACKENDS[id];
if (!backend) throw new InputError(`Unknown backend "${id}".`, "backend");
const prom = backend.family === "prom";
const labels = parseLabels(String(input.labels ?? ""), prom);
const type = String(input.type);
for (const l of labels) {
if (prom && (RESERVED[type] ?? []).includes(l.name)) {
const what = type === "histogram" ? "histogram" : "summary";
throw new InputError(
`"${l.name}" is the label a ${what} writes itself, so yours would collide with it.`,
"labels",
);
}
}
const buckets = Number(input.buckets);
if (type === "histogram" && (!Number.isInteger(buckets) || buckets < 0 || buckets > 1000)) {
throw new InputError("Buckets is a whole number from 0 to 1,000, not counting +Inf.", "buckets");
}
if (id === "gcm-custom" && type === "histogram" && buckets + 1 > 200) {
throw new InputError("Cloud Monitoring allows at most 200 buckets in a custom distribution.", "buckets");
}
const targets = Number(input.targets);
if (!Number.isInteger(targets) || targets < 1)
throw new InputError("At least 1 target exposes the metric.", "targets");
const occur = Number(input.occur);
if (!(occur > 0 && occur <= 100))
throw new InputError("The share that occurs is above 0% and at most 100%.", "occur");
const interval = Number(input.interval);
if (!(interval > 0)) throw new InputError("The scrape interval is a positive number of seconds.", "interval");
if (backend.minInterval && interval < backend.minInterval)
throw new InputError(
`${backend.name} accepts at most one point every ${backend.minInterval} s per series, so the interval is at least that.`,
"interval",
);
const price = Number(input.price ?? 0);
if (!(price >= 0)) throw new InputError("The price can't be negative.", "price");
const created = prom && Boolean(input.created);
const w = writes(id, type, buckets, created);
const per = { n: w.n, parts: w.parts };
const sets = count(labels, occur);
const tf = backend.targetsMultiply ? targets : 1;
const series = sets * per.n * tf;
const without = (l: Label) =>
count(
labels.filter((x) => x !== l),
occur,
) *
per.n *
tf;
/*
* Prometheus's instrumentation guide: keep a metric's cardinality below 10, and investigate anything over
* 100. Its "cardinality" is label sets, before the type multiplies them, which is the number judged here.
* The guide is Prometheus's, so only the Prometheus backends show it.
*/
const tone = sets > 100 ? "bad" : sets >= 10 ? "warn" : undefined;
const guide =
sets > 100
? "Over 100, where Prometheus's guide says to look for another way."
: sets >= 10
? "Prometheus's guide aims for fewer than 10."
: "Fewer than 10, as Prometheus's guide aims for.";
const ceiling =
occur === 100 && labels.filter((l) => l.values > 1).length >= 2
? " Assumes every combination of values occurs; set the share that does if you know it."
: "";
/*
* The multiplication, factor by factor, so the answer can be checked by hand. The share that occurs is a
* factor of its own, and the label sets are rounded up after it, so with a share the product is the
* ceiling and the series count can sit a few above it. A backend where every process writes into the
* same metric has no targets factor, and the method says why.
*/
const one = backend.unit === "custom metrics" ? "metric" : "series";
const factors = labels.length
? labels.map(
(l, i) =>
`${num(l.values)} ${l.name}${i === labels.length - 1 ? (l.values === 1 ? " value" : " values") : ""}`,
)
: ["1 label set"];
if (occur < 100) factors.push(`${num(occur)}% that occur`);
factors.push(`${per.n} ${per.n === 1 ? one : one === "metric" ? "metrics" : "series"} per label set`);
if (backend.targetsMultiply) factors.push(plural(targets, "target"));
/*
* Each change the reader can make, scored by the same multiplication with it made, fewest series first. The
* native histogram is in it where a classic histogram is many series, because there it is usually the
* biggest cut.
*
* ⚠️ A label's row is what DROPPING it leaves. Grouping its values into a few leaves something between that
* and today, which is why the row says "Without route" and the method notes say how to group.
*/
type Change = { name: string; after: number };
const changes: Change[] = [];
for (const l of labels) if (l.values > 1) changes.push({ name: `Without ${l.name}`, after: without(l) });
if (prom && type === "histogram" && per.n > 1 + (created ? 1 : 0))
changes.push({ name: "Native histogram", after: sets * tf });
if (created && HAS_CREATED.has(type)) changes.push({ name: "Without _created", after: sets * (per.n - 1) * tf });
changes.sort((a, b) => a.after - b.after);
const metrics: Field[] = [];
if (prom)
metrics.push({ label: "Label sets", value: num(sets), note: `${guide}${ceiling}`, ...(tone ? { tone } : {}) });
/* What the backend bills, in its own unit, and the bill when the reader gives their price for it. */
let bill: number | undefined;
const bound = w.bound ? `${w.bound[0]!.toUpperCase()}${w.bound.slice(1)} ` : "";
if (id === "prometheus") {
const perSecond = series / interval;
const kept = perSecond * 86_400 * RETENTION_DAYS;
metrics.push(
{ label: "Samples", value: `${num(perSecond)} a second`, note: `One per series every ${num(interval)} s.` },
{
label: "Disk",
value: `${bytes(kept)} to ${bytes(kept * 2)}`,
note: `Over ${RETENTION_DAYS} days, Prometheus's default retention, at 1 to 2 bytes a sample.`,
},
);
bill = (series / 1000) * price;
} else if (prom) {
const monthly = (sets * tf * w.samples * MONTH_S) / interval;
if (w.uncounted) {
metrics.push({ label: "Samples ingested", value: "Not countable from these inputs", note: w.uncounted });
} else {
const how =
id === "gmp" && type === "histogram"
? `Each scrape of a histogram is 2 samples plus 1 per bucket with a count in it, ${buckets + 1} buckets here.`
: id === "gmp" && type === "native"
? "Each scrape of a native histogram is 2 samples plus 1 per 8 buckets with counts in them."
: `One per series every ${num(interval)} s.`;
metrics.push({
label: "Samples ingested",
value: `${bound}${big(monthly)} a month`,
note: `${how} In a 30-day month.`,
});
bill = (monthly / (id === "gmp" ? 1e6 : 1e7)) * price;
}
} else if (id === "gcm-custom") {
const mib = (sets * tf * w.samples * w.bytes * MONTH_S) / interval / 2 ** 20;
metrics.push({
label: "Bytes ingested",
value: `${num(mib)} MiB a month`,
note: `${w.bytes} bytes a point, one point per series every ${num(interval)} s, in a 30-day month. The first 150 MiB a month of each billing account are free.`,
});
bill = mib * price;
} else if (id === "cloudwatch") {
metrics.push({
label: "Billed as",
value: `${plural(series, "metric-month")}`,
note: "Prorated by the hour, for the hours a metric gets data. PutMetricData requests, or an EMF log's ingestion, are billed on top.",
});
bill = series * price;
} else {
metrics.push({
label: "Billed as",
value: "Free during the preview",
note: "For new work Microsoft points to OpenTelemetry metrics in an Azure Monitor workspace, billed per sample ingested.",
});
}
if (bill !== undefined && price > 0) {
metrics.push({
label: "Bill",
value: `${bound}${money(bill)} a month`,
note: `At ${money(price)} per ${backend.priceUnit}.`,
});
}
/* A provider's limit is flagged only when this metric alone breaks it. */
const lim = backend.seriesLimit;
if (lim) {
const mine = lim.per === "target" ? series / tf : series;
if (mine > lim.n)
metrics.push({
label: "Series limit",
value: `${num(mine)} of ${num(lim.n)}`,
note: `${backend.name} allows ${num(lim.n)} ${lim.what}${lim.per === "target" ? "; this is one target's share" : ""}.`,
tone: "bad",
});
}
if (id === "azure-prom") {
const perMinute = (series * 60) / interval;
if (perMinute > 1_000_000)
metrics.push({
label: "Ingest limit",
value: `${num(perMinute)} events a minute`,
note: "Azure allows 1,000,000 events per minute ingested into a workspace.",
tone: "bad",
});
}
if (backend.labelLimit && labels.length > backend.labelLimit.n)
metrics.push({
label: "Label limit",
value: `${labels.length} labels`,
note: `${backend.name} allows ${backend.labelLimit.n} ${backend.labelLimit.what}.`,
tone: "bad",
});
const key = backend.unit === "custom metrics" ? plural(series, "custom metric") : `${num(series)} series`;
const parts: Output[] = [
{
kind: "fields",
role: "key",
fields: [{ label: backend.unit === "custom metrics" ? "Custom metrics" : "Time series", value: key }],
},
{ kind: "text", role: "formula", mono: true, text: factors.join(" x ") },
];
if (changes.length) {
parts.push({
kind: "series",
chart: {
id: "changes",
xLabel: "Change",
yLabel: backend.unit === "custom metrics" ? "Metrics" : "Series",
orientation: "horizontal",
x: [...changes.map((c) => c.name), "Now"],
series: [
{
label: backend.unit === "custom metrics" ? "Metrics" : "Series",
shape: "bar",
points: [...changes.map((c) => c.after), series],
},
],
readout: { highlightTable: true },
},
});
parts.push({
kind: "table",
role: "comparison",
caption: "Each change on its own",
columns: [
{ label: "Change", wrap: "prose", rowHeader: true },
{
label: `${backend.unit === "custom metrics" ? "Metrics" : "Series"} after`,
align: "end",
mono: true,
wrap: "none",
},
{ label: "Saves", align: "end", mono: true, wrap: "none" },
],
rows: changes.map((c) => [
c.name,
{ text: num(c.after), mono: true },
{ text: `${num(series - c.after)} (${pct(series - c.after, series)})`, mono: true },
]),
link: { chart: "changes", keys: changes.map((c) => c.name) },
});
}
parts.push({ kind: "fields", role: "metrics", fields: metrics });
const how = "How to make each change";
const worked = "How this was worked out";
const method: Field[] = [
{
group: worked,
label: prom
? "Series per label set"
: backend.unit === "custom metrics"
? "Metrics per label set"
: "Series per label set",
value: String(per.n),
note: per.parts,
},
];
if (id !== "prometheus")
method.push({ group: worked, label: "Stored in", value: backend.name, note: backend.note, mono: false });
if (!backend.targetsMultiply && targets > 1) {
method.push({
group: worked,
label: "Targets",
value: "Not a factor here",
note:
id === "cloudwatch"
? `CloudWatch treats each unique combination of dimensions as one metric, so ${num(targets)} processes publishing the same dimensions write the same metrics. If each adds its own, such as a host, list it as a label.`
: `Microsoft counts a time series per unique combination of metric and dimension values, so ${num(targets)} processes sending the same dimensions to the same resource count once. If each adds its own, such as a host, list it as a label.`,
});
}
if (id === "gcm-custom" && targets > 1) {
method.push({
group: worked,
label: "Targets",
value: "Each its own monitored resource",
note: "A time series is its metric labels plus the monitored resource it is written against, such as one k8s_container.",
});
}
if (id === "azure-custom") {
method.push({
group: worked,
label: "Interval",
value: "60 s, fixed",
note: "Azure Monitor ingests custom metrics once a minute, however often they are sent.",
});
}
if (lim && !metrics.some((m) => m.label === "Series limit")) {
method.push({
group: worked,
label: "Limit",
value: `${num(lim.n)} ${lim.what}`,
mono: false,
note: lim.per === "target" ? "Counted per target." : "Shared by every metric there, not this one alone.",
});
}
if (id === "gmp" && created) {
method.push({
group: worked,
label: "_created",
value: "Counted as the toggle says",
note: "Google's pages checked don't say whether its collector stores _created as a series.",
});
}
if (changes.some((c) => c.name.startsWith("Without ") && c.name !== "Without _created")) {
method.push({
group: how,
label: "A label",
value: "Drop it, or group its values into a few",
mono: false,
note: "The table counts dropping it; grouping leaves more. Put the detail on a span or a log line instead.",
});
}
if (changes.some((c) => c.name === "Native histogram")) {
method.push({
group: how,
label: "Native histogram",
value: "All the buckets in one series",
note:
id === "prometheus"
? "Prometheus has to have native histograms turned on."
: id === "amp"
? "Amazon accepts them, and meters each scrape at 0.25 of a sample per bucket with counts in it."
: "Microsoft's limits and pricing pages don't mention native histograms; check that yours are accepted.",
});
}
if (changes.some((c) => c.name === "Without _created")) {
method.push({
group: how,
label: "_created",
value: "PROMETHEUS_DISABLE_CREATED_SERIES=True",
note: "In Python's prometheus_client. Newer Prometheus servers can store it as metadata instead.",
});
}
parts.push({ kind: "fields", role: "method", fields: method });
return { kind: "group", parts };
} catch (error) {
if (error instanceof InputError) return { kind: "error", message: error.message, input: error.input };
throw error;
}
},
} satisfies Tool<Input>;

Related posts

2 items
  • Sep 21, 2026 27 min read Systems

    Observability-driven development

    A deploy makes checkout slow for 0.9% of requests, and the dashboard's median doesn't move. Writing down the question you'd ask if it broke, and making a test prove the telemetry can answer it, is what finds them. Plus what that telemetry costs, in series and in microseconds.

    Down to the wireobservabilitydistributed-systemstesting
  • 5 posts Series

    Down to the wire

    Distributed systems at the point where the theory meets the pager.

  • Systems

    Observability-driven development

    A deploy makes checkout slow for 0.9% of requests, and the dashboard's median doesn't move. Writing down the question you'd ask if it broke, and making a test prove the telemetry can answer it, is what finds them. Plus what that telemetry costs, in series and in microseconds.

    observabilitydistributed-systems
    Sep 2026
  • Series

    Down to the wire

    Distributed systems at the point where the theory meets the pager.

    5 posts