Skip to content

Commit 90b7a7c

Browse files
bosconiclaude
andcommitted
metrics: add dyncfg-driven export filter for environmentd metrics
A single environmentd `/metrics` response grows with the catalog: many families carry one series per object, persist shard, replica or cluster. Scrapers reject oversized responses outright, which loses every metric of the process at once, and until now the only lever was a code change and a rollout. Add a gather-time postprocessor on the metrics registry, installed once per environmentd instance from `serve`, that drops families and scopes series according to four new dyncfgs: * `metrics_export_disabled_families`: comma-separated family names with an optional trailing `*` prefix glob. * `metrics_export_cluster_allowlist`: when set, series carrying an `instance_id`, `cluster_id` or `compute_instance` label are exported only for these clusters. * `metrics_export_replica_allowlist`: the same for `replica_id`. * `metrics_export_max_series_per_family`: a family with more exported samples than this is dropped whole rather than truncated, so a dashboard sees an absent family instead of a partial one that looks complete. All default to no filtering. Counts are exported samples as the text encoder emits them, so histogram-heavy families are not undercounted. The filter reads its configuration from the live system dyncfg set environmentd already holds, re-parsing only on change, so there is no per-scrape catalog round-trip and no process-global state. The filter records what it did in `mz_metrics_export_series{family}`, `mz_metrics_export_dropped_series_total{family,reason}` and `mz_metrics_export_encoded_bytes`, which are themselves exempt from filtering so the operator can always see what was dropped. Filtering is export-side only: the series still exist in the registry, so re-enabling a family restores its current values, and the process still pays for maintaining them. Emission-side gating per family is follow-up work tracked in DB-199. The design doc in doc/developer/design covers the alternatives. Tests: unit tests for parsing, each rule, the cap, histogram sample counting, self-metric exemption, stale gauge children and live dyncfg updates in `mz_metrics::export_filter`; an environmentd integration test `test_metrics_export_filter` that flips each dyncfg via `ALTER SYSTEM` and scrapes `/metrics`. CI defaults exercise the filter path with a non-existent probe family and a high cap. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
1 parent f00ec11 commit 90b7a7c

12 files changed

Lines changed: 1158 additions & 2 deletions

File tree

Lines changed: 210 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,210 @@
1+
# Runtime control of environmentd metric cardinality
2+
3+
- Associated: [DB-199](https://linear.app/materializeinc/issue/DB-199), [INC-1252](https://app.incident.io/materializeinc/incidents/1252), [#38616](https://github.com/MaterializeInc/materialize/pull/38616)
4+
5+
## The Problem
6+
7+
environmentd exposes one `/metrics` endpoint whose sample count is proportional
8+
to catalog size: many families carry one series per catalog object, persist
9+
shard, replica or cluster. The AMP scraper rejects any response over 50 MiB,
10+
which is not configurable on our side. In INC-1252 the largest environment in
11+
the fleet (about 14k catalog objects) returned about 58 MB and 450k samples
12+
across 531 families, so every scrape failed with `body size limit exceeded`
13+
and every environmentd metric for the environment disappeared for hours,
14+
twice. Recovery was coincidental, when the sample count dipped back under the
15+
limit. The environment is growing, so it will cross the limit again.
16+
17+
Until now there was no runtime control over which families or which scopes
18+
environmentd exports. The only levers were a code change and a rollout.
19+
20+
The largest families on the affected endpoint, from the incident channel:
21+
22+
| Family | Samples | Labels |
23+
|---|---|---|
24+
| `mz_dataflow_wallclock_lag_seconds{,_sum,_count}` | 61,548 | instance_id, replica_id, collection_id |
25+
| `mz_time_to_first_row_seconds` | 52,080 | instance_id, isolation_level, strategy, application_name |
26+
| `mz_timestamp_difference_for_strict_serializable_ms` | 34,125 | compute_instance |
27+
| `mz_compute_peek_duration_seconds` | 31,176 | instance_id, result |
28+
| `mz_object_info` | 14,488 | one per catalog object |
29+
30+
Of the 173 labeled metric families in environmentd-side crates, 92 scale with
31+
objects, replicas or clusters: 43 per persist shard (Persist), 3 per
32+
collection per replica and 29 per replica or per cluster in the compute and
33+
storage controllers (Cluster), 5 per catalog object and 2 per unbounded
34+
`application_name` in the adapter (SQL).
35+
36+
## Success Criteria
37+
38+
- An operator can shed environmentd samples at runtime through `ALTER SYSTEM`,
39+
with no restart, and get well under the scrape limit.
40+
- The shedding can be scoped: per-replica or per-object detail can be kept for
41+
named clusters or replicas while dropped elsewhere. Per-replica metrics are
42+
used daily to compare old and new generation replicas during rollouts and to
43+
track OOM-looping replicas, so all-or-nothing is not acceptable.
44+
- Under growth, one family degrades at a time rather than the whole endpoint.
45+
- Payload size and per-family sample counts are observable before the limit is
46+
reached.
47+
- Every knob defaults to current behavior.
48+
49+
## Out of Scope
50+
51+
- What clusterd exposes. clusterd endpoints were not near the limit.
52+
- Changes to the metrics pipeline (compression, remote-write batching). These
53+
are tracked on the Cloud side as INC-1252 follow-ups.
54+
- Deciding which families to delete outright. That is a per-team audit;
55+
[#38614](https://github.com/MaterializeInc/materialize/pull/38614) is the
56+
first instance.
57+
58+
## Solution Proposal
59+
60+
Two layers. Layer 1 is one chokepoint owned by one team and is the incident
61+
fix, implemented in #38616. Layer 2 is per-owner work that removes the cost at
62+
the source.
63+
64+
### Layer 1: export-side filter at the registry chokepoint
65+
66+
`mz_ore::metrics::MetricsRegistry::gather` runs a list of postprocessors over
67+
the gathered families. The compute controller already uses one to add a
68+
`workload_class` label to every series carrying `instance_id`. The export
69+
filter (`mz_metrics::export_filter`) is a second postprocessor, installed once
70+
per environmentd instance from `mz_environmentd::serve`, that drops families
71+
and series before encoding according to four dyncfgs. All are
72+
`ParameterScope::Environment` and default to no filtering.
73+
74+
| dyncfg | Type | Default | Effect |
75+
|---|---|---|---|
76+
| `metrics_export_disabled_families` | comma list, trailing `*` glob | `""` | Families whose name matches are removed. |
77+
| `metrics_export_cluster_allowlist` | comma list of cluster IDs | `""` (all) | Series carrying `instance_id`, `cluster_id` or `compute_instance` are kept only for the listed clusters. Series without such a label, or with an empty value, are unaffected. |
78+
| `metrics_export_replica_allowlist` | comma list of replica IDs | `""` (all) | The same for `replica_id`. |
79+
| `metrics_export_max_series_per_family` | usize | `0` (unlimited) | A family with more exported samples than this is dropped whole and counted in a self-metric. |
80+
81+
Semantics as predicates over the output of one gather, for every retained
82+
family `f` not named with the `mz_metrics_export_` prefix, with `S(f)` its
83+
series and `samples(f)` the number of text-format lines it encodes to:
84+
85+
- `not glob_match(disabled_families, f.name)`
86+
- `for all s in S(f) with a cluster label: cluster_allowlist is empty or label value is empty or label value in cluster_allowlist`
87+
- `for all s in S(f) with a replica label: replica_allowlist is empty or label value is empty or label value in replica_allowlist`
88+
- `max == 0 or samples(f) <= max`
89+
- `S(f)` is non-empty
90+
91+
Design decisions:
92+
93+
- **Samples, not label sets.** The cap and the series gauge count what the
94+
text encoder emits. A histogram series expands to one line per bucket plus
95+
`+Inf`, `_count` and `_sum`, so counting label sets would undercount
96+
histogram-heavy families by an order of magnitude, and those are exactly the
97+
families that dominated the incident.
98+
- **Whole-family drop at the cap, never truncation.** A truncated family is an
99+
arbitrary subset that looks complete on a dashboard. An absent family is
100+
visibly absent, and the `*_info` dashboards already fall back when a family
101+
is missing.
102+
- **Self-metrics are exempt.** The filter's own families are never filtered,
103+
so a small cap or a broad prefix cannot hide the metrics that explain what
104+
was dropped.
105+
- **Configuration is read from the live system dyncfg set.** environmentd
106+
already holds an `Arc<ConfigSet>` built from every dyncfg that the storage
107+
controller updates on every system-config change. The filter reads the four
108+
raw values at each gather and re-parses only when they change, so there is
109+
no catalog round-trip per scrape, no process-global state, and no separate
110+
update hook to keep in sync. The parsed configuration is shared through an
111+
`Arc` swapped under a short mutex hold, so a coordinator config update never
112+
waits on an in-flight filter pass.
113+
- **Per-instance, not per-process.** The test harness starts several
114+
environmentd instances in one process, each with its own registry, so the
115+
filter is installed from `serve` rather than from the binary's `main`.
116+
- **Where it applies.** The postprocessor filters every consumer of
117+
`gather()`. `/metrics/public` gathers environmentd's own families through it
118+
and then merges clusterd-sourced series it fetches separately, so the filter
119+
governs environmentd's own series only. That is the intended scope.
120+
121+
Self-metrics, registered by the filter:
122+
123+
| Metric | Labels | Purpose |
124+
|---|---|---|
125+
| `mz_metrics_export_series` (gauge) | `family` | Samples exported per family at the previous gather, after filtering. This is the leading indicator we lacked during the incident. |
126+
| `mz_metrics_export_dropped_series_total` (counter) | `family`, `reason` in {`disabled_family`, `cluster_allowlist`, `replica_allowlist`, `over_cap`} | What the filter is doing. |
127+
| `mz_metrics_export_encoded_bytes` (gauge) | none | Size of the most recently encoded internal `/metrics` response. |
128+
129+
Suggested alert: `mz_metrics_export_encoded_bytes > 30 MiB` fleet-wide, well
130+
under the 50 MiB limit. `topk(10, mz_metrics_export_series)` gives the
131+
reduction candidates per environment.
132+
133+
Cost: gather still materializes every registered series before the filter
134+
runs. The filter fixes the payload and the scrape, not the process-side CPU
135+
and memory of maintaining the series. That is Layer 2.
136+
137+
### Layer 2: emission-side gating per owner
138+
139+
Each per-object family gets a dyncfg that stops series from being created, so
140+
the process stops paying for them and leaks are bounded. Ordered by sample
141+
contribution in the incident environment.
142+
143+
- **Cluster: `mz_dataflow_wallclock_lag_seconds{,_sum,_count}`.** Created per
144+
collection per replica by `wallclock_lag_metrics` in `mz_cluster_client`,
145+
from the compute controller and the storage controller. Proposal: dyncfg
146+
`wallclock_lag_metrics_scope` with values `all` (default), `replica`
147+
(aggregate over collections into a per-replica family), `none`.
148+
Per-collection lag remains queryable through
149+
`mz_internal.mz_wallclock_global_lag_history`.
150+
- **SQL: the `application_name` label** on `mz_time_to_first_row_seconds` and
151+
`mz_adapter_commands`. The label is client-controlled and unbounded, and a
152+
histogram multiplies it by about 19 buckets. Proposal: dyncfg
153+
`adapter_metrics_application_name_label` with values `full` (default),
154+
`allowlist` (names not in a configured list collapse to `other`), `off`.
155+
- **Persist: `ShardsMetrics`.** 43 families with one series per shard.
156+
Proposal: dyncfg `persist_shard_metrics_enabled` in `PersistConfig`. When
157+
false, `ShardMetrics::new` builds unregistered local metric instances so
158+
call sites keep working with no branching.
159+
- **SQL: the `*_info` families.** A zero
160+
`catalog_info_metrics_reconcile_interval` stops reconciliation but leaves
161+
existing series in place. Change: zero also clears the series.
162+
- **Cluster: per-replica and per-cluster controller metrics.** Bounded by
163+
replica and cluster count rather than object count. Cover with Layer 1
164+
allowlists first.
165+
166+
### Cross-cutting: declare cardinality class at the definition
167+
168+
`metric!` accepts `tags:` and `visibility:` as documentation-only metadata
169+
consumed by `bin/gen-metrics-catalog`. Add a `scales_with:` field with values
170+
`Object`, `Shard`, `Collection`, `Replica`, `Cluster`, `Client`, keep a
171+
name-to-class map in the registry at registration, and let
172+
`metrics_export_disabled_families` accept `class:per_object` in addition to
173+
family names. A metrics-catalog lint that fails CI when a metric has a
174+
scaling label and no class keeps the inventory above from rotting.
175+
176+
## Minimal Viable Prototype
177+
178+
#38616 implements Layer 1 in full: the four dyncfgs, the postprocessor, the
179+
self-metrics, unit tests for every rule, and an environmentd integration test
180+
that flips each dyncfg through `ALTER SYSTEM` and scrapes `/metrics`. CI
181+
system-parameter defaults run the filter path with a non-existent probe family
182+
and a high cap so the postprocessor executes in every mzcompose-based test
183+
without changing what tests observe. The drop branches themselves are covered
184+
by the unit and integration tests, not by the CI defaults.
185+
186+
## Alternatives
187+
188+
- **Emission-side gating only.** Fixes process cost as well as payload, but
189+
needs a change in each owning crate and gives no single lever during an
190+
incident. It is Layer 2, sequenced after the chokepoint.
191+
- **Gzip on the endpoint.** About 10x smaller payload, but AMP's limit is
192+
checked against the decoded body in practice and compression does not
193+
reduce sample count. Tracked on the Cloud side as a complementary
194+
mitigation.
195+
- **Truncating an over-cap family** instead of dropping it. Rejected because a
196+
partial family is indistinguishable from a complete one on a dashboard.
197+
- **A process-global filter updated through `mz_metrics::update_dyncfg`.**
198+
The first implementation. Rejected because the test harness runs several
199+
environmentd instances per process and the coordinator's config update
200+
would have contended with an in-flight filter pass.
201+
202+
## Open questions
203+
204+
1. Should `/metrics/public` also filter the clusterd-sourced series it merges
205+
in? The current scope is environmentd's own series only.
206+
2. Is per-collection wallclock lag as a Prometheus metric load-bearing for any
207+
alert, or is `mz_wallclock_global_lag_history` sufficient? Decides whether
208+
the `replica` aggregate in Layer 2 is needed or `none` is enough.
209+
3. Should the cap default to a non-zero value in production once the
210+
self-metrics have a few weeks of data?

doc/user/data/metrics.yml

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1228,6 +1228,23 @@ metrics:
12281228
help: The current VmSwap metric.
12291229
source: src/compute/src/memory_limiter.rs
12301230
visibility: internal
1231+
- name: mz_metrics_export_dropped_series_total
1232+
help: Samples removed from the exported metrics by the export filter.
1233+
labels:
1234+
- family
1235+
- reason
1236+
source: src/metrics/src/export_filter.rs
1237+
visibility: internal
1238+
- name: mz_metrics_export_encoded_bytes
1239+
help: Size in bytes of the most recently encoded internal metrics response.
1240+
source: src/metrics/src/export_filter.rs
1241+
visibility: internal
1242+
- name: mz_metrics_export_series
1243+
help: Number of samples exported per metric family at the previous gather, after filtering.
1244+
labels:
1245+
- family
1246+
source: src/metrics/src/export_filter.rs
1247+
visibility: internal
12311248
- name: mz_metrics_lgalloc_allocations_total
12321249
help: Number of region allocations in size class
12331250
labels:

misc/python/materialize/mzcompose/__init__.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -213,6 +213,19 @@ def get_variable_system_parameters(
213213
"100ms",
214214
["10ms", "100ms", "1s", "10s"],
215215
),
216+
# Exercise the metrics export filter without changing what tests
217+
# observe: the disabled family does not exist and the cap is far above
218+
# any real family's series count.
219+
VariableSystemParameter(
220+
"metrics_export_disabled_families",
221+
"mz_export_filter_ci_probe_*",
222+
["", "mz_export_filter_ci_probe_*"],
223+
),
224+
VariableSystemParameter(
225+
"metrics_export_max_series_per_family",
226+
"1000000",
227+
["0", "1000000"],
228+
),
216229
VariableSystemParameter(
217230
"persist_next_listen_batch_retryer_fixed_sleep",
218231
"1200ms",
@@ -818,6 +831,8 @@ def get_default_system_parameters(
818831
"mz_metrics_lgalloc_refresh_interval",
819832
"mz_metrics_rusage_refresh_interval",
820833
"mz_metrics_usage_refresh_interval",
834+
"metrics_export_cluster_allowlist",
835+
"metrics_export_replica_allowlist",
821836
"compute_peek_response_stash_batch_max_runs",
822837
"compute_peek_response_stash_read_batch_size_bytes",
823838
"compute_peek_response_stash_read_memory_budget_bytes",

misc/python/materialize/parallel_workload/action.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2942,6 +2942,14 @@ def __init__(
29422942
)
29432943
self.flags_with_values["enable_eager_delta_joins"] = BOOLEAN_FLAG_VALUES
29442944
self.flags_with_values["enable_public_metrics_endpoint"] = BOOLEAN_FLAG_VALUES
2945+
self.flags_with_values["metrics_export_disabled_families"] = [
2946+
"''",
2947+
"'mz_export_filter_ci_probe_*'",
2948+
]
2949+
self.flags_with_values["metrics_export_max_series_per_family"] = [
2950+
"0",
2951+
"1000000",
2952+
]
29452953
self.flags_with_values["persist_batch_structured_key_lower_len"] = [
29462954
"0",
29472955
"1",
@@ -3394,6 +3402,8 @@ def __init__(
33943402
"mz_metrics_lgalloc_refresh_interval",
33953403
"mz_metrics_rusage_refresh_interval",
33963404
"mz_metrics_usage_refresh_interval",
3405+
"metrics_export_cluster_allowlist",
3406+
"metrics_export_replica_allowlist",
33973407
"compute_peek_stash_num_batches",
33983408
"compute_peek_stash_batch_size",
33993409
"compute_peek_response_stash_batch_max_runs",

src/environmentd/src/http.rs

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -182,6 +182,8 @@ pub struct HttpConfig {
182182
pub dyncfgs: Arc<ConfigSet>,
183183
pub metrics: Metrics,
184184
pub metrics_registry: MetricsRegistry,
185+
/// Records the encoded size of `/metrics` responses.
186+
pub metrics_export_filter: mz_metrics::ExportFilter,
185187
pub mcp_metrics: mcp_metrics::McpMetrics,
186188
pub oauth_metadata_metrics: oauth_metadata::OauthMetadataMetrics,
187189
pub internal_route_config: Arc<InternalRouteConfig>,
@@ -242,6 +244,7 @@ impl HttpServer {
242244
dyncfgs,
243245
metrics,
244246
metrics_registry,
247+
metrics_export_filter,
245248
mcp_metrics,
246249
oauth_metadata_metrics,
247250
internal_route_config,
@@ -487,8 +490,11 @@ impl HttpServer {
487490
.route(
488491
"/metrics",
489492
routing::get(move |headers: HeaderMap| async move {
490-
mz_http_util::handle_prometheus(&metrics_registry_for_handler, headers)
491-
.await
493+
mz_http_util::handle_prometheus_with(
494+
&metrics_registry_for_handler,
495+
headers,
496+
|bytes| metrics_export_filter.record_encoded_bytes(bytes),
497+
)
492498
}),
493499
)
494500
.route(

src/environmentd/src/lib.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -415,6 +415,10 @@ impl Listeners {
415415
let adapter_client_rx = adapter_client_rx.shared();
416416

417417
let metrics_registry = config.metrics_registry.clone();
418+
let metrics_export_filter = mz_metrics::ExportFilter::install(
419+
&metrics_registry,
420+
Arc::clone(&config.system_dyncfgs),
421+
);
418422
let metrics = http::Metrics::register_into(&metrics_registry, "mz_http");
419423
let mcp_metrics = http::mcp_metrics::McpMetrics::register_into(&metrics_registry);
420424
let oauth_metadata_metrics =
@@ -445,6 +449,7 @@ impl Listeners {
445449
dyncfgs: Arc::clone(&config.system_dyncfgs),
446450
metrics: metrics.clone(),
447451
metrics_registry: metrics_registry.clone(),
452+
metrics_export_filter: metrics_export_filter.clone(),
448453
mcp_metrics: mcp_metrics.clone(),
449454
oauth_metadata_metrics: oauth_metadata_metrics.clone(),
450455
internal_route_config: Arc::clone(&internal_route_config),

0 commit comments

Comments
 (0)