Appendix F · The observability model, in full
What this holds
| Purpose | The book’s sizing exemplar, including what is not yet measured |
| Model | models/observability/model.yaml |
| Built from | observability-reference, observability-knobs_turned_down |
Metrics, logs and traces, for an estate stated in ch02’s terms. Vendor neutral: nothing on this page names a product, and the structure is what transfers.
This is the book’s second model, and it shows what Appendix E does not. Three multiplicative chains hang off the same few roots, so they move together whether or not anybody says so. Label cardinality is a product of uncertain counts and therefore dominates everything downstream of it. The control knobs are sampling and retention rather than money. Three tiers, ingest, store and query, carry four ceilings between them, and no single number summarises them.
And it has a hole in it, deliberately left open.
What is not yet measured
Not measured yet
observability declares 2 constant(s) that nobody has measured:
collector throughput per core, measured — needs
bench/results/collector-throughput-per-core.jsonspans per request — needs
bench/results/traces-spans-per-request.json
8 node(s) downstream of those cannot be computed and are shown as — rather than filled in. Nothing is estimated in their place: this book publishes measurements or it publishes nothing.
Source — observability-reference · every input on a slider · 2 constant(s) not yet measured
Spans per request is a property of somebody’s instrumented application at a particular version.
It is not a property of any corpus, so this repository cannot derive it, and it is not a property
of any machine, so the reference rig cannot measure it either. It belongs to the estate target:
an observation somebody takes of a system they run.
Nobody has taken it. So the whole traces chain has no value, and the figures below show that rather than filling it in. This is not a gap waiting to be tidied up before publication. It is what an honest sizing model looks like before the work is done, and putting it on a published page is the clearest statement this book can make about the difference between a missing number and an invented one.
The same is true of collector throughput per core. The model declares it twice on purpose: once as the vendor’s quoted figure, which lets a ceiling be computed, and once as a measurement nobody has taken, which leaves a second ceiling with nothing in it but the words not yet measured. Reading those two rows next to each other is most of ch03 · Where the numbers come from.
The graph
Try it
The same model, as an interactive page. Turn the four knobs and watch which ceilings move, and which one does not. The knobs-turned-down scenario is the same model with all four already moved.
The unmeasured constants are drawn hollow, and everything downstream of them is dimmed. The dimmed nodes are the answer to “what would measuring this one thing unlock”, and nobody had to write that answer down.
Every formula
The graph above, as text: every derived quantity and every ceiling, with the formula the file gives it. The table is rendered from the file, so it cannot disagree with it.
| Quantity | Formula | Unit |
|---|---|---|
horizon periods (horizon_periods) | horizon / one_year | |
growth factor (growth_factor) | annual_growth ** horizon_periods | |
label cardinality (label_cardinality) | label_values_endpoint * label_values_status * label_values_accidental | |
active series (active_series) | hosts * base_metrics_per_host * label_cardinality * growth_factor | series |
sample rate (sample_rate) | one_sample_per_series / scrape_interval | sample/series/second |
metrics ingest (metrics_ingest) | active_series * sample_rate * metrics_sample_bytes | MB/s |
metrics stored (metrics_stored) | metrics_ingest * metrics_retention | TB |
log line rate (log_line_rate) | request_rate * lines_per_request * log_keep_rate * growth_factor | line/second |
logs ingest (logs_ingest) | log_line_rate * logs_line_bytes | MB/s |
logs stored (logs_stored) | logs_ingest * logs_retention | TB |
span rate (span_rate) | request_rate * spans_per_request * trace_keep_rate * growth_factor | span/second |
traces ingest (traces_ingest) | span_rate * traces_span_bytes | MB/s |
traces stored (traces_stored) | traces_ingest * traces_retention | TB |
ingest, metrics and logs only (known_ingest) | metrics_ingest + logs_ingest | MB/s |
total ingest (total_ingest) | metrics_ingest + logs_ingest + traces_ingest | MB/s |
quoted pipeline capacity (quoted_pipeline_capacity) | collector_cores * collector_throughput_quoted | MB/s |
measured pipeline capacity (measured_pipeline_capacity) | collector_cores * collector_throughput_measured | MB/s |
ingest utilisation, quoted (quoted_pipeline_utilisation) | known_ingest / quoted_pipeline_capacity against a limit of 1, keeping 0.4 below it | |
ingest utilisation, measured (pipeline_utilisation) | total_ingest / measured_pipeline_capacity against a limit of 1, keeping 0.4 below it | |
installed usable (installed_usable) | store_nodes * usable_tb_per_node | TB |
stored, metrics and logs only (known_stored) | metrics_stored + logs_stored | TB |
retention store fill (store_fill) | known_stored / installed_usable against a limit of 1, keeping 0.3 below it | |
series per query (series_per_query) | series_per_query_base * label_cardinality | series/query |
query series rate (query_series_rate) | queries_per_second * series_per_query | series/second |
query capacity (query_capacity) | query_nodes * query_scan_rate_quoted | series/second |
query path utilisation (query_utilisation) | query_series_rate / query_capacity against a limit of 1, keeping 0.4 below it | |
retention cost, metrics and logs only (known_storage_cost) | known_stored * storage_price | USD/month |
Source — models/observability/model.yaml
The three chains
| Output | Point estimate | 90% interval | Unit |
|---|---|---|---|
| metrics ingest | 4.69 | 0.709 to 29.1 | MB/s |
| logs ingest | 44.7 | 9.66 to 164 | MB/s |
| traces ingest | not yet measured | not yet measured | MB/s |
| traces stored | not yet measured | not yet measured | TB |
| ingest, metrics and logs only | 49.4 | 12.5 to 182 | MB/s |
| stored, metrics and logs only | 278 | 74.6 to 1,301 | TB |
| active series | 15,019,917 | 2,269,389 to 93,180,409 | series |
| label cardinality | 76.3 | 17.9 to 300 | |
| retention cost, metrics and logs only | $4,085 | $957.47 to $21,788 | USD/month |
| retention store fill | 0.579 | 0.155 to 2.71 | |
| ingest utilisation, quoted | 0.514 | 0.130 to 1.90 | |
| query path utilisation | 0.569 | 0.0591 to 3.37 | |
| ingest utilisation, measured | not yet measured | not yet measured |
Source — observability-reference · every input on a slider · 2 constant(s) not yet measured
Metrics are cheap in bytes and expensive in series. Logs are the reverse. Traces would be somewhere in between, and the model declines to guess.
Look hardest at the active series count. A cardinality node drives it, and that node is the product of three uncertain counts, and a product of uncertain things is far more uncertain than any of them:
The four ceilings, one of which cannot be computed
| Ceiling | At the plan | Headroom | Allowed | Limit | Verdict | Over allowed | Over limit |
|---|---|---|---|---|---|---|---|
| ingest utilisation, measured | not yet measured | ||||||
| query path utilisation | 0.57 | 40% | 0.60 | 1.00 | ok | 43% | 28% |
| ingest utilisation, quoted | 0.51 | 40% | 0.60 | 1.00 | ok | 44% | 21% |
| retention store fill | 0.58 | 30% | 0.70 | 1.00 | ok | 45% | 30% |
Source — observability-reference · every input on a slider · 2 constant(s) not yet measured
Three tiers, three different mechanisms, three different margins: that is the argument for declaring headroom per ceiling rather than globally. The ingest and query margins are about queueing, where response time climbs long before anything is busy (ch06). The store margin is about rebuild, where losing a node costs capacity you were using (ch11). They only look alike because they are both percentages.
Two of the three that can be computed rest on an incomplete total. The because on each says so,
and ch20 is about what it costs to forget.
What moves the answer
| Input | Kind | active series at its p10 | at its p90 | Swing |
|---|---|---|---|---|
| accidental label values | input | 6,131,856 | 36,791,133 | 30,659,278 |
| annual growth | input | 8,356,817 | 26,995,673 | 18,638,856 |
| label values endpoint | input | 8,016,214 | 25,640,826 | 17,624,612 |
| label values status | input | 11,136,220 | 20,748,006 | 9,611,786 |
| lines per request | input | 15,019,917 | 15,019,917 | 0 |
| bytes per log line, compressed | measured | 15,019,917 | 15,019,917 | 0 |
| bytes per sample, compressed | measured | 15,019,917 | 15,019,917 | 0 |
| queries per second | input | 15,019,917 | 15,019,917 | 0 |
Source — observability-reference · every input on a slider · 2 constant(s) not yet measured
The accidental label is at the top of the tornado: the one nobody planned, added during an incident and never removed. ch08 is the chapter about that, and here it is demonstrated rather than warned about.
Turning the knobs
| Output | Reference scenario | The four knobs, turned down |
|---|---|---|
| metrics ingest | 4.69 0.709 to 29.1 | 2.35 0.354 to 14.6 |
| logs ingest | 44.7 9.66 to 164 | 4.47 0.966 to 16.4 |
| traces ingest | not yet measured | not yet measured |
| traces stored | not yet measured | not yet measured |
| ingest, metrics and logs only | 49.4 12.5 to 182 | 6.81 1.90 to 27.5 |
| stored, metrics and logs only | 278 74.6 to 1,301 | 29.8 7.90 to 142 |
| active series | 15,019,917 2,269,389 to 93,180,409 | 15,019,917 2,269,389 to 93,180,409 |
| label cardinality | 76.3 17.9 to 300 | 76.3 17.9 to 300 |
| retention cost, metrics and logs only | $4,085 $957.47 to $21,788 | $438.25 $101.75 to $2,374 |
| retention store fill | 0.579 0.155 to 2.71 | 0.0621 0.0165 to 0.296 |
| ingest utilisation, quoted | 0.514 0.130 to 1.90 | 0.0710 0.0198 to 0.287 |
| query path utilisation | 0.569 0.0591 to 3.37 | 0.569 0.0591 to 3.37 |
| ingest utilisation, measured | not yet measured | not yet measured |
| query path utilisation — over its limit | 28% | 28% |
| ingest utilisation, quoted — over its limit | 21% | 0% |
| retention store fill — over its limit | 30% | 0% |
Source — observability-reference and observability-knobs_turned_down · every input on a slider · 4 constant(s) not yet measured
The right-hand column doubles the scrape interval, cuts metric retention by most of a year, keeps a tenth of the log lines and samples one trace in a hundred. Ingest and storage fall a long way.
The query ceiling does not move at all. Not by a little. Not at all, because none of the four knobs touches cardinality, and cardinality is what a query has to walk past. The controls a platform gives you operate on the chains that were already affordable, and the input that decides the size of the system is not one of them. That is the most useful thing on this page. Fixing cardinality means a conversation with whoever added the label.
Where the inputs came from
| Input | Provenance | Source | |
|---|---|---|---|
| ○ | annual growth | assumption | ch04 — telemetry grows faster than the traffic it describes, because teams add instrumentation as well as load. Lognormal like any growth rate — it compounds and cannot go negative — and wider than the web service’s for the same reason its median is higher |
| ○ | metric names per host | assumption | distinct metric names exposed per host, before labels |
| ○ | collector cores | assumption | the sizing decision for the ingest tier |
| ◐ | collector throughput quoted | vendor claim | throughput per core as documented by the collector. Unverified here, and marked as a claim in every figure it appears in |
| ○ | horizon | assumption | the planning horizon for this platform |
| ○ | hosts | assumption | stated estate size (ch02) |
| ○ | accidental label values | assumption | ch08 — the cardinality explosion, as a distribution rather than as a warning. Lognormal because this one has no ceiling: a label carrying a user id multiplies the series count by a number nobody chose, and a triangular would assert a maximum that does not exist |
| ○ | label values endpoint | assumption | distinct routes appearing as a label value. Triangular because somebody could go and count them this afternoon; the maximum is the routes that exist today, and a new service adds to it |
| ○ | label values status | assumption | distinct status classes kept as a label. Triangular, and the one input in this model whose bounds are genuinely hard: a request cannot carry a status the code does not emit |
| ○ | lines per request | assumption | application log lines emitted per request served. Triangular, from reading a sample of the logs — and the maximum is the part to distrust, because a debug flag left on in production is outside it |
| ○ | fraction of log lines kept | assumption | a control knob (ch12): drop and aggregation rules |
| ○ | logs retention | assumption | a control knob (ch12) |
| ○ | metrics retention | assumption | a control knob (ch12) |
| ● | one sample per series | fact | definition |
| ● | one year | fact | definition |
| ○ | queries per second | assumption | dashboards, alert rules and people, at the busy hour. Triangular because two of those three are countable and the third is not |
| ○ | query nodes | assumption | the sizing decision for the query tier |
| ◐ | query scan rate quoted | vendor claim | series scanned per second per query node, quoted |
| ○ | request rate | assumption | ch03 — the busy hour across the estate, not the daily mean. Triangular: a minimum, a likely and a maximum is the whole of what a dashboard gives you, and the maximum is the busiest hour anybody has looked at rather than the busiest one there will be |
| ○ | scrape interval | assumption | a control knob (ch12): doubling it halves the metrics chain and loses resolution that cannot be recovered afterwards |
| ○ | series per query, before labels | assumption | how many distinct metric-name-and-host combinations one dashboard panel or alert rule covers, before any label expansion. Triangular, and wide: dashboards differ from each other more than anything else in this model does |
| ○ | storage price | assumption | all-in cost of a usable TB-month on this tier. Lognormal because it is a price — and carried across by hand from whichever storage tier’s unit economics somebody had to hand, rather than by sampling, which is the seam ch18 is about |
| ○ | store nodes | assumption | the sizing decision for the retention tier |
| ○ | trace sampling rate | assumption | a control knob (ch12), and the one with the widest range: head sampling at 1% and at 100% are two different platforms |
| ○ | traces retention | assumption | a control knob (ch12) |
| ○ | usable tb per node | assumption | usable capacity per storage node after replication |
| 26 inputs | 2 fact, 2 vendor claim, 22 assumption |
Source — observability-reference · every input on a slider · 2 constant(s) not yet measured
| Constant | Value | Standard error | Unit | Measured against |
|---|---|---|---|---|
| collector throughput per core, measured | not yet measured | — | MB/s/core | bench/results/collector-throughput-per-core.json does not exist |
| bytes per log line, compressed | 32.4 | ± 0.0044 | bytes / line | python zlib (DEFLATE level 6) |
| bytes per sample, compressed | 9.37 | ± 0.0009 | bytes / sample | this repository’s encoder, not any product’s |
| spans per request | not yet measured | — | span/request | bench/results/traces-spans-per-request.json does not exist |
| bytes per span, compressed | 73.6 | ± 0.0173 | bytes / span | python zlib (DEFLATE level 6) |
Source — observability-reference · every input on a slider · 2 constant(s) not yet measured
Running it yourself
python3 -m bench.run_models --model observability
python3 scripts/verify-models.py
python3 scripts/render-figures.py
python3 -m pytest tests/test_models.pyTo close the hole: measure spans per request on a system you run, stamp it as an estate
observation with the system and the window disclosed, and every dimmed node on this page lights
up with no other change to the model.