Verify a release candidate by comparing metrics across the release boundary, then report the outcome.
The release bot posts a request in the #release Slack channel naming the version and linking one dashboard per area. Each area is verified by that area's team. This skill covers the shared method plus per-area metric references.
Prerequisites
This workflow needs the Grafana MCP server against grafana.dev.materialize.com. Verify it answers before starting:
mcp__grafana__list_datasources with type "prometheus"
Reading the bot request also needs the Slack MCP server, but a user-pasted dashboard link or version string is enough to proceed without it.
Read the panel instructions, then apply this skill
Two dashboards carry their own Signing off on Releases text panel, and they say different things. Read both at the start of every run, because they are maintained separately from this skill:
mcp__grafana__get_dashboard_property
uid: f248986d-81c6-42a6-817b-00cd7759d808 # compute
jsonPath: $.panels[?(@.type=="text")].options.content
mcp__grafana__get_dashboard_property
uid: e6dc7745-7d35-4968-a23d-689883a984bb # storage-overview
jsonPath: $.panels[?(@.type=="text")].options.content
If a panel and this skill disagree, the panel wins on what to inspect and this skill wins on how to measure it. Report the disagreement so one of them gets fixed.
Every dashboard names its variables differently
This is the single most common source of a wasted query. There is no shared convention.
| Dashboard | Environment variable | Others |
|---|---|---|
compute-overview |
namespace |
version, organization, cluster_id, replica_id, worker_id, collection_id |
storage-overview |
env |
cluster, replica, object_id, pod |
storage-upsert-sources |
namespace |
pod, source |
persist |
env |
pod |
environmentd-health |
env |
pod |
release-health |
organization, an organization id, not a namespace |
mz_cluster, version |
networking |
namespace |
pod, tenant |
The release bot sets both var-namespace and var-env on every link for this reason.
Step 1: Establish the version and the boundaries
Every judgement in this workflow is a before/after comparison, so the upgrade times must be pinned first. Ask Prometheus which version each environment runs over the past 10 days:
count by (mz_version) (group by (namespace, mz_version) (v2_mz_compute_cluster_status{mz_version!~".*-dev.*"}))
Run it as a range query with a 6h step. The -dev exclusion drops personal development environments, which run arbitrary old builds and only add noise.
Two facts come out of this. The release under test is the highest vX.Y.0-rc.N present, and the boundary per stack is the step where the previous version disappears and it appears. Expect two boundaries for the same release, because a later rc usually supersedes an earlier one, and both are the new release for sign-off purposes.
A second, sharper boundary marker is the process count doubling. Zero-downtime upgrades run the old and new generation side by side, so any per-pod series count roughly doubles for one bucket:
count(avg_over_time(container_memory_working_set_bytes{pod=~".*cluster-.*-replica-.*", container="clusterd"}[6h]))
Use those doubling buckets as the boundary, and exclude them from both the before and after samples. They contain two full fleets and will corrupt any sum.
Step 2: Choose the namespace set
The dashboards' version variable does not filter panels directly. It narrows the organization variable, which narrows namespace, and the panels filter on namespace. Setting version to the new release therefore means "the environments that run the new release now", and the panels then show those same environments on both sides of the boundary. Reproduce that selection explicitly rather than relying on the variable chain.
Production and staging need different selectors. In production only the canary environments run a release candidate, and they share the plain released version with every customer environment for part of the week, so a version-based filter loses them. Derive their namespaces once and pin them by name:
group by (namespace, mz_context_org_name) (v2_mz_compute_cluster_status{mz_version=~"<new release>.*"})
The canary organizations are Materialize Production Sandbox and Materialize Production Analytics. Which regions carry which has changed over time, so always derive rather than assume, and note that the bot's links and Prometheus have disagreed on this. Sources and Sinks historically inspected only the sandbox environment.
The canonical canary list lives in MaterializeInc/release, in templates/issue.md, as the --environment arguments to bin/deploy upgrade production. The storage-overview sign-off panel points instead at MaterializeInc/cloud/.github/ISSUE_TEMPLATE/03-release.md, which no longer exists.
In staging every environment runs a release candidate, so a version join both selects the right set and excludes development environments:
sum(rate(<metric>[6h]) * on(namespace) group_left()
group by (namespace) (v2_mz_compute_cluster_status{mz_version=~".*-rc[.].*"}))
Escaping note: write -rc[.] rather than -rc\\. so the expression survives JSON encoding unchanged.
Step 3: Choose the time window
The bot's links default to roughly now-2d, resolved to an absolute timestamp at post time. That is too short. The window must contain a clean stretch of the previous release, the boundary, and a clean stretch of the new release, which in practice means seven days or more. Look back far enough to include the previous upgrade, so that the previous boundary is available as a calibration reference.
A window of Sun 12:00 UTC through the current day at 12:00 UTC with a 6h step has worked well. The 6h step averages away diurnal structure while leaving enough points to see a step.
Sample the three phases separately, and never straddle a boundary or a doubling bucket:
- Previous release, steady state.
- New release under the earlier
rc, steady state. - New release under the current
rc.
Step 4: Derive the metric roster
Take the roster from the dashboard itself so it cannot drift out of date:
mcp__grafana__get_dashboard_property uid: <dashboard uid> jsonPath: $.panels[*].title
mcp__grafana__get_dashboard_panel_queries uid: <dashboard uid>
The panel titles are cheap and give the row structure. The panel queries are not. On the compute dashboard that call returns about 62 KB across 165 panels, which overflows the tool result and is written to a file instead. Slice that file with a script and extract only metric names and label selectors. Never read it whole, and never paste it into the conversation.
Row panels appear in the title list but not in the query list, so the two are offset. Match them by title, not by index.
scripts/panel-metrics.py does the slicing. Take the roster from the dashboard, but resolve every name against the metrics catalog for the release under test before trusting it, as described in When a metric has gone missing below. A dashboard panel outlives the metric it plots, so the panel list is a superset of what the build actually exports.
Step 5: Measure
Batch many metrics into one range query by tagging each aggregate with a synthetic label and combining with or. This turns twenty tool calls into one:
label_replace(sum(rate(<counter>[6h])), "m", "1_name", "", "")
or label_replace(sum(avg_over_time(<gauge>[6h])), "m", "2_name", "", "")
Prefix the tags so the result order is stable and readable. Use rate(x[6h]) for counters and avg_over_time(x[6h]) for gauges, both matching the step so buckets do not overlap.
scripts/build-range-query.py writes these expressions from a metric list, because twenty of them by hand is where typos live. It also encodes the one thing that differs between stacks, namely the staging release-candidate join against the pinned production canary namespaces, so the same roster can be run against either without rewriting the selector.
Run each area twice. Once across all clusters, and once restricted to the system clusters, which the panel instructions call out because a system-cluster regression is easy to lose in the noise of user clusters. System clusters are instance_id=~"s[0-9]+" for the controller and replica metrics, and pod=~".*cluster-s[0-9]+-replica-.*" for the container metrics.
Step 6: Judge
There are no thresholds, so the discipline is in ruling out the confounders before believing a signal.
Calibrate before calling anything a regression. Fleet composition and workload drift produce steps of the same size as most real regressions, so pull three weeks of the metric and look at its natural spread before believing a step. Do this live rather than against a recorded figure, because the spread itself changes as the fleet does. When this was first measured, staging fleet clusterd CPU ranged from 2.83 to 5.55 cores over three weeks, which put a 3% step across the boundary far inside the noise; the useful part of that observation is its size, roughly a factor of two, not the numbers.
Compare at equal post-restart age. Every upgrade restarts clusterd, and a fresh process holds less memory than one that has been running for days. Comparing the pre-upgrade level against the post-upgrade level therefore flatters the new release, and comparing a post-upgrade level against a mid-week pre-upgrade level exaggerates a regression. Sample both sides at a similar age since restart, and treat a monotonic climb within one release as more informative than any level difference across the boundary.
Read the base level of bimodal metrics. Arrangement record counts and sizes swing by a factor of three or more as periodic dataflows rebuild. Compare the low state against the low state; spike heights are not comparable.
Discount pre-existing noise. Some staging environments crashloop or carry permanently erroring dataflows. In August 2026 staging us-east-1 sustained roughly 90 clusterd restarts per 6h and staging eu-west-1 carried 50 to 400 dataflow errors continuously, both flat across the boundary. Flat means not release-related. The panel instructions suggest filtering such environments out with the dashboard variables, which is worth doing when they mask everything else.
Separate a signal from its location. A fleet-wide step and one environment moving the fleet total are different findings. Split them with a per-namespace ratio of the two windows:
sort_desc(sum by (namespace) (rate(<metric>[15h]))
/ sum by (namespace) (rate(<metric>[42h] offset 126h)))
Set the instant query's end time to the end of the new window, and pick the offset so the second window lands in the previous release.
Step 7: Drill deeper
When a signal survives Step 6, tighten the aggregation one level at a time. Each level costs one query and narrows the search:
- Fleet total, which establishes that something moved.
- Per namespace, which finds the environments involved.
- Per cluster, via
instance_id, which separates system from user clusters. - Per replica, via
replica_id, which distinguishes a replica-local effect from a cluster-wide one. - Per worker, via
worker_id, which exposes skew across workers of one replica. - Per collection, via
collection_id, which names the dataflow.
Below the metric layer, hand off rather than guess. Use mz-profile for CPU and memory attribution inside a process, mz-query-tracing for the latency breakdown of a statement, and the Polar Signals MCP server for on-CPU profiles of a running environment. If the signal implicates a specific change, mz-debug-ci covers finding the responsible build.
Step 8: Report
State the verdict first, then the method, then the findings. The method matters because the reader has to judge whether the comparison was fair: name the version, the boundaries, the window, the namespace selection, and which regions and rows were covered. Say explicitly what was skipped and why.
Separate release-blocking findings from notes. A note is something worth a second look next release that does not survive Step 6 as a regression. Give each note its magnitude and the reason it is not conclusive, so the next person can compare against it rather than rediscover it.
Sign-off happens in the bot's thread. As of August 2026 there is a proposal to sign off by reacting to each team's message rather than replying, which was not yet confirmed; check the thread's convention before posting, and never post to Slack without the user asking.
Areas
The bot links one dashboard per area. All are on grafana.dev.materialize.com.
| Area | Dashboard | UID | Reference |
|---|---|---|---|
| Compute | compute-overview |
f248986d-81c6-42a6-817b-00cd7759d808 |
references/compute.md |
| Sources and Sinks | storage-overview |
e6dc7745-7d35-4968-a23d-689883a984bb |
references/sources-and-sinks.md |
| Sources and Sinks | storage-upsert-sources |
ac2de0ab-4a35-48b5-93aa-7e645569debb |
references/sources-and-sinks.md |
| Persist | persist |
m3U1U6ZVk |
references/persist.md |
| Adapter | environmentd-health |
mR1Kg1d4z |
references/adapter.md |
| Reference | release-health |
zKe0K0N4z |
references/reference-dashboards.md |
| Reference | networking |
bHQE8bN4k |
references/reference-dashboards.md |
Each reference names the metrics, their types and labels, the invariants that hold at any fleet size, and the hazards specific to that area. Read the one for the area you are verifying before running a single query. The Storage label the bot still uses refers to work now split between Sources and Sinks and Persist.
Sweep sizes differ by an order of magnitude. Compute has about 60 sweep-relevant panels, adapter about 45, storage-overview about 55, and persist 375 panel targets over roughly 230 metrics. For persist, the dashboard's own should be small panel defines the sweep; see its reference.
The bot links production only, but staging is a larger and earlier sample of the same release, and Adapter verification has covered staging in practice. Prefer running both.
Datasource UIDs, which the dashboards take as the datasource variable:
| Stack | Region | UID |
|---|---|---|
| Production | us-east-1 | 2K85O21Vz |
| Production | eu-west-1 | E0J0O2J4k |
| Production | us-west-2 | ee2e6227-dc2d-4ca5-bb58-ca826fd6d614 |
| Staging | us-east-1 | Ks85Oh14z |
| Staging | eu-west-1 | JKT0Oh1Vk |
| Staging | us-west-2 | c979f3ee-16d0-44f1-9f24-9e208e0326d9 |
Staging us-west-2 held no compute environments in August 2026, so v2_mz_compute_cluster_status returns nothing there. Confirm it is still empty rather than reporting a region as clean.
Characterizing an area
All seven dashboards have a reference, written from a sweep of v26.38.0-rc.3 against v26.37.0 in August 2026 across production canary and staging in us-east-1, with compute additionally covering production eu-west-1, production us-west-2, and staging eu-west-1.
To characterize a new dashboard, or to refresh one, run Steps 1 through 6 against it for one release and record in references/<area>.md what the run taught you:
- Each metric with its type, the labels that select cluster and replica, and what it means.
- Which metrics are bimodal, restart-sensitive, or absent when zero.
- Hazards and invariants, in one section rather than two. Each entry leads with the property that holds at any fleet size, such as one counter equalling the difference of two others, a gauge whose only meaningful aggregate is a series count, or a metric that is structurally absent in one stack, and then gives the measurement it came from. Splitting these across two sections produced near-verbatim duplication in every reference, because almost every hazard is an invariant with a number attached.
- Label naming inconsistencies, duplicate-series hazards, and any panel expression whose filters are not what they appear to be.
- Known noise classes, meaning the environments that are unhealthy independently of any release and whose flat contribution can dominate a fleet aggregate.
Record invariants, not levels. A recorded level is stale the week after it is written, because environments are created, deleted, and resized continuously, and a stale reference value is worse than none: it invites a comparison the reader should not make. The comparison that matters is always derived in-run, since the before-window of your own query is the only baseline guaranteed to describe the same fleet as the after-window.
Coarse order-of-magnitude figures are worth keeping for one narrow purpose: catching a mis-scoped selector, for example a missing container="clusterd" that inflates a result tenfold. Keep them dated, keep them to one significant figure, and say plainly that they are not for comparison.
When a metric has gone missing
A reference that names a metric the build no longer exports is worse than no reference, because the sweep reads the empty result as a healthy zero. Two things make this checkable, and both have to be applied to the right version.
doc/user/data/metrics.yml is the generated catalog of metric! invocations in the Rust tree. It is checked in, so it is tagged along with each release, and the working tree's copy describes main, which is ahead of whatever you are verifying. Reading that copy during a sign-off answers a question you did not ask. Read the catalog at the two release tags instead and diff them:
scripts/catalog-diff.sh v26.38.0 v26.39.0-rc.3
That turns "is this empty panel a regression or a rename?" into a lookup. A name in the removed list explains an after-window that went empty, and it is a documentation fix rather than a finding. A name in the added list explains an empty before-window, and comparing across the boundary on it is meaningless.
Catalog membership is not a string match. A metric! whose name is built with format! is catalogued with its placeholders globbed, so mz_persist_user_bytes and mz_persist_compaction_goodbytes are both covered by the single entry mz_persist_*_bytes, and histograms are catalogued as their expanded _bucket, _count, and _sum families rather than under the base name. Grepping the catalog for a literal name will therefore report a live metric as missing. Match against the patterns, as scripts/lint_metrics.py does.
The catalog covers only this repository's Rust tree. It holds no v2_mz_*, which the promsql exporter derives from SQL, no container_*, kube_*, or kubelet_* from cAdvisor and kube-state-metrics, and no cloud-side names such as mz_envd_up or mz_external_*. Those families are confirmed against Prometheus with list_prometheus_metric_names, and they are where the sweep has actually been bitten: the compute dashboard's scratch-disk panels plot kubelet_volume_stats_used_bytes, which resolves to five series in a single namespace in both production and staging us-east-1 and none of them a clusterd volume, so those panels render blank in both stacks.
The diff survives that blind spot, because both sides share it and a dynamically named metric is missing from both catalogs and so never appears in either list. The diff therefore never reports a rename that did not happen. It can still miss one, so an empty result that the diff does not explain is not yet cleared. Confirm it against Prometheus with list_prometheus_metric_names before reporting the metric as zero.
When a name resolves in neither the catalog nor Prometheus, the panel that plots it is dead, and the reference should record that rather than the metric. git log -S<name> --all -- src/ settles which kind of dead it is, and the two kinds read differently in a report. mz_query_latency was real, added in #22049 and deleted in #26647 along with the stash, so the environmentd-health panel that still plots its _bucket family has been empty since that deletion. mz_persist_columnar_validation_count and mz_txn_placeholder_schema_apply have never appeared in this repository at all, yet both are live arms of the persist dashboard's should be small panel, and the working spelling of the first sits beside it on the same panel as mz_persist_columnar_op_count with op="validation" and result="invalid".
ci/test/lint-skill-metrics.sh guards the other direction, so the references cannot rot silently between releases. It resolves every mz_* name in this skill against the working tree's catalog, patterns included, and fails on any that neither resolves nor appears in scripts/metrics-allowlist.txt. The allowlist is the point: it carries one line per name the catalog does not cover, with the reason, so adding to it is a deliberate act and a genuinely renamed metric still fails. It also fails on an allowlist entry that has started resolving, so the exemptions cannot outlive their reason. A failure is an instruction to update the skill, never to suppress the lint.
Traps
A gauge divided by a limit can fail on duplicate series. container_spec_memory_limit_bytes is exported once per node-label set, and labels such as karpenter_sh_initialized flip during node lifecycle, which yields two series for one pod and a many-to-many matching not allowed error. Collapse both sides first:
max by (namespace, pod, container) (avg_over_time(container_memory_working_set_bytes{...}[6h]))
/ on (namespace, pod, container)
max by (namespace, pod, container) (avg_over_time(container_spec_memory_limit_bytes{...}[6h]))
An absent series is not the same as a healthy zero. Error and orphan counters are only exported when non-zero, so an empty result reads as clean when it can also mean the metric was renamed. Confirm the metric exists somewhere in the window before reporting zero.
Label names are not consistent across metrics. Most compute metrics carry instance_id and replica_id, but the arrangement maintenance metric carries cluster_environmentd_materialize_cloud_cluster_id and cluster_environmentd_materialize_cloud_replica_id instead. Copy selectors from the panel expressions rather than writing them from memory.
Some panel filters are variable-substitution artifacts. Several compute panels append instance_id!="$cluster_id", which exists to blank a series when a single cluster is selected and is not a semantic filter. Reproducing it in an aggregate query is unnecessary.
Counter resets hide inside long rate windows. Pods are replaced at every upgrade, so restart counters reset. Prefer counting the pods whose last termination matched a condition over summing increases across a boundary:
count(kube_pod_container_status_last_terminated_exitcode{pod=~".*cluster-.*-replica-.*", container="clusterd"} == 137)
Which exit code matters depends on the dashboard. The compute dashboard looks for 137, an OOM kill. The release-health non-clusterd restart panel joins against != 166, treating 166 as an expected termination. Do not carry one dashboard's convention into another.
An empty panel is the most dangerous reading on any dashboard. Metrics get renamed and panels do not follow, so the panel renders blank and looks like health. This was not hypothetical: the sweep found four dead expressions on environmentd-health and two on storage-overview, all silently empty. Before reporting any metric as zero, confirm the name still exists:
mcp__grafana__list_prometheus_metric_names datasourceUid: ... regex: mz_catalog.*
The per-area references list the dead panels found so far. Check for new ones whenever a panel that should have data does not.
Some odd-looking expressions are deliberate and must be preserved. Two idioms recur. An or between two aggregations, as on storage-overview's controller protocol panels, straddles a metric rename so the panel keeps working across the version boundary; imitate it rather than deleting the dead arm. A trailing ^0, as in networking's egress panels, raises a series to the power zero to yield 1 for every series that exists, making it a set-membership filter rather than arithmetic.
An implausibly constant quantile is a bucket artifact, not stability. A histogram_quantile landing inside one wide bucket returns the bucket boundary and cannot move, which reads as a rock-steady latency. Two adapter panels do this. When a quantile is stable to five significant figures while its counters advance, switch to rate(_sum) / rate(_count).
Counting series is sometimes the measurement. Several metrics are per-entity gauges whose only useful aggregate is a series count: mz_persist_shard_upper for shards, mz_balancer_metadata_seconds for balancers, mz_persist_metadata_seconds by version for persist client builds. Similarly mz_source_progress is a millisecond frontier timestamp, so a healthy series contributes exactly 1000 to its rate and the panel's absolute value is really a series count in disguise.