Lesson 29 / 30
Cluster Observability
The cost of collecting logs, metrics, and traces at cluster scale is counted: how many resources, how many series per resource, how many records and how many bytes once multiplied by a retention period. Label cardinality's multiplicative growth in series count is shown; the bytes won and the detail lost are measured together once sampling and aggregation are applied.
Published Updated
In this lesson
- Ties series count to a single formula: resource count times the product of each metric's label cardinalities
- Shows with numbers why adding one label grows series multiplicatively, not additively
- Measures sampling and aggregation's two faces together: bytes won and the question that can no longer be answered
Contents
Every number in the previous lesson came out of a single pipeline’s eighty-round trace, and what kept that trace was measurement: three items per round, two hundred forty reads in total. A cluster holds not one but dozens of resources, and every one of their controllers does the same reading. Measurement itself becomes a load.
This lesson does not argue over what a metric is — counter, gauge, and histogram were established in M16/K07 and appear here only by name. The only thing measured is the cost of collection at cluster scale: how many resources, how many series per resource, how many records and how many bytes once multiplied by a retention period. The delegated decision follows from this too: which question stays answerable later gets decided at the moment of collection, and that decision cannot be undone, because detail that is not collected sits nowhere.
The measurement is again over the regional measurement network and is fictional; no real
cluster is run, and what is counted is a model built with node.
- OO49. The cluster inventory is fictional: 34 pods and 9 nodes belonging to five applications.
- OO50. Metric types were established in M16/K07; they are referred to here only by name.
- OO51. A record is 16 bytes (timestamp 8, value 8), a series’ identity is 120 bytes; the sampling interval is 15 seconds, retention is 30 days.
- OO52. Series count is the sum, over resource counts, of the product of each metric’s label cardinalities.
- OO53. The latency signal is produced with our own generator, seed
20260801; the twelve spikes are hand-written. - OO54. Twelve operating questions and the label each one requires are hand-written.
Series Arithmetic at Cluster Scale
// cluster.mjs — cluster inventory, metric definitions, and series / record / byte arithmetic. export const RESOURCE = { pod: 34, node: 9 }; // five applications, 34 pods, 9 nodes export const SAMPLE_INTERVAL_SEC = 15, RETENTION_DAYS = 30; export const RECORD_BYTES = 16, SERIES_BYTES = 120; // timestamp 8 + value 8; series identity 120 export const METRIC = { // name: [type, { label: cardinality }] pod: [ ["request-count", "counter", { path: 12, method: 3, result: 4 }], ["request-duration", "histogram", { path: 12, bucket: 12 }], ["queue-length", "gauge", {}], ["processed-readings", "counter", { pipeline: 4 }], ["memory-usage", "gauge", {}], ], node: [ ["node-cpu", "gauge", { core: 8 }], ["node-memory", "gauge", {}], ["disk-io", "counter", { device: 3, direction: 2 }], ], }; export const seriesCount = (metric, resource = RESOURCE) => Object.entries(metric).reduce((t, [type, list]) => t + resource[type] * list.reduce((s, [, , e]) => s + Object.values(e).reduce((c, k) => c * k, 1), 0), 0); export const dropLabel = (metric, name) => Object.fromEntries(Object.entries(metric).map(([t, l]) => [t, l.map(([a, type, e]) => [a, type, Object.fromEntries(Object.entries(e).filter(([k]) => k !== name))])])); export const records = (series, interval = SAMPLE_INTERVAL_SEC, days = RETENTION_DAYS) => series * Math.floor(86400 / interval) * days; export const bytes = (series, interval = SAMPLE_INTERVAL_SEC, days = RETENTION_DAYS) => records(series, interval, days) * RECORD_BYTES + series * SERIES_BYTES; export const gb = (b) => (b / 1024 ** 3).toFixed(1); // adding a label multiplies the affected metric's series count export const addLabel = (metric, target, name, cardinality) => Object.fromEntries(Object.entries(metric).map(([t, l]) => [t, l.map(([a, type, e]) => target.includes(a) ? [a, type, { ...e, [name]: cardinality }] : [a, type, e])]));
// run15.mjs — cost of collection at cluster scale, and the multiplicative growth from label cardinality. import { RESOURCE, METRIC, SAMPLE_INTERVAL_SEC, RETENTION_DAYS, seriesCount, records, bytes, gb, addLabel } from "./cluster.mjs"; const series = seriesCount(METRIC); const product = (l) => l.reduce((s, [, , e]) => s + Object.values(e).reduce((c, k) => c * k, 1), 0); console.log(`resources ${RESOURCE.pod + RESOURCE.node} (pods ${RESOURCE.pod}, nodes ${RESOURCE.node})` + ` series per pod ${product(METRIC.pod)} series per node ${product(METRIC.node)} total series ${series}`); console.log(`sampling ${SAMPLE_INTERVAL_SEC} sec, retention ${RETENTION_DAYS} days` + ` -> records ${records(series).toLocaleString("en-US")} bytes ${gb(bytes(series))} GB`); console.log("\nas labels get added (only to the named metrics)"); console.log("label added total series factor records GB"); const show = (name, oo) => { const s = seriesCount(oo); console.log(`${name.padEnd(43)} ${String(s).padStart(9)} ${(s / series).toFixed(1).padStart(6)}` + ` ${records(s).toLocaleString("en-US").padStart(13)} ${gb(bytes(s)).padStart(7)}`); return oo; }; let o = show("baseline", METRIC); o = show("+ region (7) to request metrics", addLabel(o, ["request-count", "request-duration"], "region", 7)); o = show("+ counter-type (23) to processed-readings", addLabel(o, ["processed-readings"], "counter-type", 23)); o = show("+ version (4) to every pod metric", addLabel(o, METRIC.pod.map(([a]) => a), "version", 4)); show("+ request-id (5000) to duration", addLabel(o, ["request-duration"], "request-id", 5000)); const days = RETENTION_DAYS * 86400; // thirty-day cost of three signals on the same cluster const SIGNAL = [["metric (15 sec sampling)", records(series) * 16], ["log (12 lines/sec per pod, 180 bytes)", RESOURCE.pod * 12 * 180 * days], ["trace (220 req/sec, 6 spans, 260 bytes)", 220 * 6 * 260 * days], ["trace (with one percent sampling)", (220 * 6 * 260 * days) / 100]]; console.log("\nthirty-day collection cost for three signals"); for (const [name, b] of SIGNAL) console.log(` ${name.padEnd(44)} ${gb(b).padStart(9)} GB`);
resources 43 (pods 34, nodes 9) series per pod 294 series per node 15 total series 10131 sampling 15 sec, retention 30 days -> records 1,750,636,800 bytes 26.1 GB as labels get added (only to the named metrics) label added total series factor records GB baseline 10131 1.0 1,750,636,800 26.1 + region (7) to request metrics 68883 6.8 11,902,982,400 177.4 + counter-type (23) to processed-readings 71875 7.1 12,420,000,000 185.1 + version (4) to every pod metric 287095 28.3 49,610,016,000 739.3 + request-id (5000) to duration 685590007 67672.5 118,469,953,209,600 1765416.5 thirty-day collection cost for three signals metric (15 sec sampling) 26.1 GB log (12 lines/sec per pod, 180 bytes) 177.3 GB trace (220 req/sec, 6 spans, 260 bytes) 828.5 GB trace (with one percent sampling) 8.3 GB
Forty-three resources, two hundred ninety-four series per pod, fifteen series per node — ten thousand one hundred thirty-one series in total. Under thirty-day retention, that is one billion seven hundred fifty million records and twenty-six gigabytes. This is the baseline state with no label added, and it grows linearly as long as the resource count stays at thirty-four: double the pod count, and series doubles too.
Adding a label is not linear. When the region label’s seven values are added to two metrics,
series climbs from ten thousand to sixty-nine thousand — the resource count has not changed, the
added information is seven distinct values, but the result is a 6.8x multiplier. The reason sits
in one line: a metric’s series count is the product of its labels’ cardinalities. The
request-count metric already carried twelve paths, three methods, and four results; adding seven
regions takes it from a hundred forty-four series to a thousand and eight.
The last two rows show the limit. When the version label gets added to every pod metric, the total climbs to a 28.3x multiplier, seven hundred thirty-nine gigabytes. When request id — five thousand distinct values — gets added to a single metric, series climbs to six hundred eighty-five million, and storage reaches petabyte territory. This row is not a recommendation, it is a warning sign: use a field with unbounded cardinality as a label, and a metric store turns into a record store.
Comparing the three signals also shows where the cost sits. On the same cluster, metrics are twenty-six gigabytes, logs a hundred seventy-seven, traces eight hundred twenty-eight. Traces are the most expensive, because they carry each individual request; with one percent sampling they drop to eight gigabytes, and at that point ninety-nine of every hundred requests lose their trail.
Sampling and Aggregation
// sample.mjs — sampling and aggregation: lost detail counted together with bytes won. import { RESOURCE, METRIC, seriesCount, dropLabel, bytes, gb } from "./cluster.mjs"; const generator = (t) => () => (t = (t * 1103515245 + 12345) % 2147483648) / 2147483648; const rand = generator(20260801); // seed visible const SPIKE = [180, 420, 655, 900, 1210, 1480, 1755, 2010, 2290, 2640, 2980, 3310]; const latency = Array.from({ length: 3600 }, (_, i) => 118 + Math.round(rand() * 8) + (SPIKE.some((a) => i >= a && i < a + 20) ? 780 : 0)); console.log(`one hour of latency signal: 3600 seconds, ${SPIKE.length} spikes (each 20 sec, +780 ms)`); console.log("interval samples spikes caught seen peak series records/day total GB"); const series = seriesCount(METRIC); for (const k of [1, 15, 60, 300]) { const idx = [...latency.keys()].filter((i) => i % k === 0); const caught = SPIKE.filter((a) => idx.some((i) => i >= a && i < a + 20 && latency[i] > 500)).length; const peak = Math.max(...idx.map((i) => latency[i])); console.log(`${String(k).padStart(4)} sec ${String(idx.length).padStart(7)}` + `${String(`${caught}/${SPIKE.length}`).padStart(14)}${String(`${peak} ms`).padStart(15)}` + `${String(Math.floor(86400 / k)).padStart(24)}${gb(bytes(series, k)).padStart(12)}`); } const QUESTION = [["which path got slow", ["path"]], ["which method is erroring", ["method"]], ["which result code increased", ["result"]], ["latency's upper percentile", ["bucket"]], ["which pipeline fell behind", ["pipeline"]], ["which pod is consuming memory", ["resource"]], ["which core filled up", ["core"]], ["total request count", []], ["which application is slow", []], ["which pod restarted", ["resource"]], ["which result on which path", ["path", "result"]], ["disk direction: read or write", ["direction"]]]; console.log("\naggregation series GB questions answered detail lost"); const level = [["none", [], RESOURCE], ["bucket dropped", ["bucket"], RESOURCE], ["+ path dropped", ["bucket", "path"], RESOURCE], ["+ pods aggregated into apps", ["bucket", "path", "resource"], { pod: 5, node: 9 }]]; for (const [name, dropped, resource] of level) { const o = dropped.filter((d) => d !== "resource").reduce((x, d) => dropLabel(x, d), METRIC); const s = seriesCount(o, resource); const answered = QUESTION.filter((q) => q[1].every((e) => !dropped.includes(e))).length; console.log(`${name.padEnd(30)} ${String(s).padStart(5)} ${gb(bytes(s)).padStart(5)}` + `${String(`${answered}/${QUESTION.length}`).padStart(15)} ${dropped.length ? dropped.join(", ") : "-"}`); }
one hour of latency signal: 3600 seconds, 12 spikes (each 20 sec, +780 ms) interval samples spikes caught seen peak series records/day total GB 1 sec 3600 12/12 906 ms 86400 391.3 15 sec 240 12/12 905 ms 5760 26.1 60 sec 60 5/12 903 ms 1440 6.5 300 sec 12 1/12 903 ms 288 1.3 aggregation series GB questions answered detail lost none 10131 26.1 12/12 - bucket dropped 5643 14.5 11/12 bucket + path dropped 781 2.0 9/12 bucket, path + pods aggregated into apps 230 0.6 7/12 bucket, path, resource
The sampling table lines up two columns side by side. A fifteen-second interval takes up one-fifteenth the bytes of second-by-second collection — twenty-six gigabytes instead of three hundred ninety-one — and catches all twelve of the twelve spikes, because a spike lasts twenty seconds and the fifteen-second grid lands at least once in every twenty-second window. Up to this point, sampling looks free.
At sixty seconds, bytes drop to a quarter, six and a half gigabytes, and the spikes caught fall from twelve to five. At three hundred seconds, bytes are one point three gigabytes, and only one of the twelve spikes shows up at all. Where the loss comes from is clear: once the sampling interval exceeds the event’s duration, whether the event becomes visible is left to chance. The peak seen sits around nine hundred milliseconds across all three intervals — meaning a caught spike is measured correctly, and what gets lost is not magnitude but how many spikes never show up at all. The metric’s average stays correct, and stays misleading.
The aggregation table builds the same duality on the label side. When the histogram bucket is dropped, series falls from ten thousand to five thousand six hundred forty-three, and eleven of twelve questions stay answerable — the one lost question is latency’s upper percentile. When the path label is also dropped, series falls to seven hundred eighty-one, bytes to two gigabytes, and two more questions close. When pods get aggregated into applications, series falls to two hundred thirty, bytes to six hundred megabytes; which pod is consuming memory, or which one restarted, can no longer be asked.
The three steps’ payoff is not equal. The first step buys back 11.6 gigabytes and costs one question; the second buys back 12.5 gigabytes and costs two; the third buys back only 1.4 gigabytes and again costs two. Gain per question falls from 11.6 to 6.3 to 0.7 gigabytes. What is cheap is dropping the product’s large factor — the twelve-bucket histogram and the twelve-path distinction. Once those two factors are gone, the remaining series are already few, and every further aggregation closes a question for close to free bytes.
Delegation’s Balance Sheet
| Measure | Value |
|---|---|
| resources | 43 (34 pods, 9 nodes) |
| series per pod / per node | 294 / 15 |
| total series, records, bytes (15 sec, 30 days) | 10,131 / 1.75 billion / 26.1 GB |
| one label (7 values) added to two metrics | series 6.8x, bytes 177.4 GB |
| unbounded-cardinality label (5000 values) | series 67,672x |
| metric / log / trace (30 days) | 26.1 / 177.3 / 828.5 GB |
| sampling 15 → 60 sec | bytes 26.1 → 6.5 GB, spikes caught 12/12 → 5/12 |
| aggregation, three steps | bytes 26.1 → 0.6 GB, questions answered 12/12 → 7/12 |
The delegated decision is this: which question stays answerable later gets decided before the question is asked. The information items this needs are countable too — which metric, which labels, which interval, how much retention. Information’s cost is the product of four numbers, and changing a single item in that product multiplies the result many times over.
What the system does not know also does something, and this time it is silent. When a sixty-second sample interval is set up, no warning appears; because seven spikes never show up, their not showing up also does not show up. When the pod label is dropped, a query produces no error, it returns an application-level number instead. Both of these losses get noticed months after the decision was delegated, and only once that question is genuinely asked; at that point there is no way to reach back and collect the data that was never gathered.
Summary
- 43 resources, 294 series per pod and 15 per node; 10,131 series in total, 1.75 billion records and 26.1 GB over 30 days.
- A metric’s series count is the product of its label cardinalities: adding a 7-value label to two metrics grows the total 6.8x, adding a 4-value label to every pod metric grows it 28.3x.
- Turning a 5000-value field into a label multiplies series by 67,672x; unbounded cardinality turns a metric store into a record store.
- On the same cluster, the 30-day cost is 26.1 GB for metrics, 177.3 for logs, 828.5 for traces; one-percent sampling cuts traces to 8.3 GB and erases the trail of 99 of every 100 requests.
- Moving sampling from 15 to 60 seconds drops bytes from 26.1 to 6.5 GB and spikes caught from 12/12 to 5/12; three aggregation steps drop bytes to 0.6 GB while dropping questions answered from 12/12 to 7/12.
Next Step
Every measurement up to this point was over a single cluster: one control plane, one resource inventory, one series store. The regional measurement network’s production runs across two regions, and each region has its own cluster — the same chart, the same resource type, the same controller, separate control planes. The next lesson counts what this multiplication costs: how many clusters the same desired state reaches by how many separate routes, how drift between clusters gets measured, and, when a decision has to be made not for a single cluster but for a set of clusters, which information item comes from whom.
To keep your progress and take notes, Log in
My notes
Log in to take notes.