Dashboard Style Guidelines#
Conventions for building visually consistent, operator-friendly dashboards. The audience for the dashboards themselves is Materialize end users: database-literate operators with basic graph-reading fluency but minimal cloud / Kubernetes / observability expertise. SQL is fair game; jargon like “differential dataflow’s arrangement” needs a one-liner explanation when it appears.
Layouts#
Prefer automatic layouts over fixed positioning. Dashboard v2 provides more ergonomic options like Tabs and a formal automatic layout system.
- Prefer
AutoGridLayoutover fixed positioning. - Use
.max_column_count(N)to tune density. For panels with wide legend tables (multiple calc columns + long pod names), 2 columns per row is a good default; for compact stat panels, the default 3 or bumping to 5 (e.g. workload readiness) is fine. - Column-width sizing (
AutoGridLayout.column_width_mode(...)):"narrow"— rows of mostly-stat panels alongside one or two donuts; keeps the donut from stealing all the horizontal space."wide"— rows of complex panels (timeseries with table legends, histograms, bar charts, tables). Lets each panel get enough room to be readable; on smaller monitors the row scrolls horizontally rather than cramming everything into a too-narrow column.- Default (
"standard") is fine for typical mixes.
- Do not wrap a small set of related panels in nested sub-rows when the auto-layout will tile them correctly — let
AutoGridLayouthandle the 2D wrap.
Collapsed rows for type-specific drilldowns#
When a row only applies to a subset of environments — e.g. Iceberg-sink metrics only matter when Iceberg sinks exist — declare the row collapsed by default with .collapse(True):
def build_iceberg_sinks_row(self):
return (
dashboardv2_builders.Row()
.title("Iceberg Sinks")
.hide_header(False)
.collapse(True) # collapsed; expand on demand
.layout(...)
)Operators can expand the row when they need it; the row title acts as documentation that the section exists. This keeps the default page light without losing the type-specific content.
Dashboard v1 compatibility (IGNORE THIS SECTION)#
Ignore this section until v1 support is desired.
We build dashboards as v2 by default and then provide best-effort compatibility with v1.
For Dashboard v1 compatibility, we use Collapsed rows as a replacement for v2 Tabs.
We do not provide direct positions, but instead calculate grid positions based on a 24-column grid system. The default height of rows is 9.
Palettes#
We offer a few colorblind-friendly palettes for use in dashboards. Grafana does not provide colorblind-friendly palettes by default.
packages/grafana-dashboards/dashboards/palette.py— qualitative + sequential palettespackages/grafana-dashboards/dashboards/threshold.py— threshold-based color/text mappings
Read the comments in dashboards.palette and dashboards.threshold for intended usage.
Tab-level theming#
For non-health metrics (counts, totals, capacity, etc.) where there’s no intrinsic good/bad coloring, pick a tab-level theme shade and use it across all stat-style panels in that tab. The convention is:
# At module scope, near the top of each tab's file:
COMPUTE_THEME = palette.THEME_PALETTE[3] # pick a distinct index per tabPass the shade through to visualization.sparkline_stat(shade=…) (see Sparkline stats). This gives each tab a visually distinct background hue without re-deriving the choice in every panel.
palette.THEME_PALETTE is an alias of BRIGHT_QUALITATIVE_NONSEQ (7 entries). The index assigned per tab is the project’s source of truth for visual identity — see the dashboard inventory in the dashboards-as-code skill for current assignments.
Variables#
Exposed variables should live inside of dashboard.variables and be explicitly registered in a given dashboard within the configure_variables method (or configure_datasources for datasource variables). Variables are global to all panels within the dashboard.
Advanced controls#
For variables which should generally be left on their defaults but may be modifiable for “power users”, use the “Controls” section of the variable editor (in v2: inControlsMenu; in v1: VariableHide “3”).
Intermediates#
Intermediate variables are variables that are computed from other variables and are hidden from the UI (in v2: hideVariable; in v1: VariableHide “2”).
The hidden discovery variables still use this pattern — e.g. mzNamespaceList, which is derived from environmentIdList.
Do not use Constant Variables for reusable PromQL filter snippets.
We used to express shared label-matcher fragments ($environmentFilter, $containerFilter, $clusterFilter, $replicaFilter) as hidden Constant Variables whose values chained other variables.
Grafana’s constant-variable interpolation does not recursively resolve those nested $…List references and mangles the embedded commas/quotes when the value is spliced into a label matcher, so the rendered query broke.
These are now inlined as Python fragments at generation time instead — a module constant variables.ENVIRONMENT_FILTER and a variables.container_filter(*extra) helper — interpolated into the query f-strings.
The nested $…List references inside those fragments stay as real Grafana variables and resolve at view time; only the wrapper indirection is gone.
Multi-select variables in regex contexts#
For multi-select variables (multi: true) used in PromQL label matchers, prefer the explicit :regex interpolation format when the variable is embedded inside a wider regex string.
Grafana auto-detects the regex format only for the simple direct case label=~"$var". When the variable appears inside a larger pattern, auto-detection does not fire, and bare $var resolves to literal $__all (or a {val1,val2} glob form) that doesn’t behave as alternation.
# Direct usage — auto-detected, plain `$var` is fine:
compute_cluster_id=~"$mzClusterList"
# Embedded usage — use `:regex` to get `(val1|val2|…)`:
pod=~".*-cluster-${mzClusterList:regex}-replica-${mzReplicaList:regex}-.*"This is the same guidance Grafana’s own MCP tool surfaces in its dashboard-authoring hints.
Panel visualization conventions#
Shared panel-styling helpers live in packages/grafana-dashboards/dashboards/visualization.py. Prefer importing from there over hand-rolling per-tab versions. It currently exports:
NO_FILTER_MATCH— standard “no value” string for panels driven by multi-select filters (e.g., “No matches for the current filters”).PIE_LEGEND_BUILDER— pre-configured piechart legend (table layout, right placement, value column).TS_LEGEND_BUILDER— pre-configured timeseries legend (table layout, bottom placement, Max / Avg / Last calcs).sparkline_stat(shade=…)— factory returning astat.Visualizationwith the area-mode sparkline pre-configured and (optionally) a fixed background shade.
Sparkline stats#
For “count” / “total” / “capacity” style metrics, prefer visualization.sparkline_stat(...) over a plain stat:
from dashboards import palette, visualization
COMPUTE_THEME = palette.THEME_PALETTE[3] # one shade per tab
.visualization(
visualization.sparkline_stat(shade=COMPUTE_THEME)
.unit("short")
.min(0) # anchor the sparkline Y-axis at zero for count-style metrics
)Two non-obvious requirements:
- Use a range query, not
.instant(). Sparklines need a series of points to render; if the query is instant, the panel will show the big number but the sparkline footer will be blank. Donuts / piecharts / single-value panels still want.instant()— the rule is “only instant queries when a single point is exactly what’s being displayed.” .min(0)for counts. Without it, Grafana auto-zooms the sparkline Y-axis to the data’s actual range, which makes a count that drifts from 64 to 66 look like a huge swing. Anchor to zero so the magnitude is visible.
Partitioned sparkline stats#
When a sparkline-stat query produces multiple series (e.g. sum by (session_type) (...) returning system and user rows), the stat panel renders one tile per series. In that case set text_mode=VALUE_AND_NAME so each tile labels itself with its series name; otherwise you get a row of bare numbers with no indication of which is which.
.visualization(
visualization.sparkline_stat(shade=MY_THEME)
.min(0)
.text_mode(common.BigValueTextMode.VALUE_AND_NAME)
)For single-series sparklines, leave the default VALUE text mode — the panel title is the label.
Timeseries legend#
Apply the shared timeseries legend builder to every multi-series timeseries panel:
.visualization(
timeseries.Visualization()
.unit("Bps")
.no_value(CADVISOR_MISSING)
.legend(visualization.TS_LEGEND_BUILDER)
)Notes:
- Placement BOTTOM gives the table room for the per-series name + calc columns without truncation; RIGHT works for short legends only.
- Avg ->
mean, Last ->lastNotNull. Plainlastincludes nulls and surprises users when the most recent scrape was missing.
Donut / pie legend#
.visualization(
piechart_builder.Visualization()
.pie_type(piechart.PieChartType.DONUT)
.legend(visualization.PIE_LEGEND_BUILDER)
.display_labels([piechart.PieChartLabels.NAME, piechart.PieChartLabels.VALUE])
.no_value(visualization.NO_FILTER_MATCH)
)“No data” messaging#
Every panel that depends on an optional or filterable metric source should set .no_value("…") with a self-explanatory reason. Reach for the closest existing constant rather than inventing new wording:
visualization.NO_FILTER_MATCH— multi-select filter excluded everything (cluster/replica/namespace selection).CADVISOR_MISSING/KSM_MISSING(defined ink8s_resources.py) — required scrape target is absent.
This way a blank panel tells the operator why it is blank.
Color-mode default#
For stat panels showing values that aren’t intrinsically good/bad (counts, totals, capacity), use color_mode=NONE so the value renders in the default text color rather than green. visualization.sparkline_stat already does this. For health metrics, use color_mode=BACKGROUND plus an explicit thresholds/mappings palette (see dashboards.threshold).
Writing panel descriptions#
Grafana renders panel .description(...) text as a hover tooltip and a full info dialog (click the panel’s title chevron). It supports GitHub-flavored Markdown. Descriptions are the operator’s first-line documentation for “what am I looking at” — invest in them.
Audience#
Write for a Materialize end user: someone with database experience and basic familiarity reading graphs, but minimal cloud / Kubernetes / observability expertise. Assume SQL fluency. Explain Materialize-side concepts (peek, hydration, arrangement) when they appear. Don’t restate the obvious (“Network bandwidth per pod” — they can read the title).
Structure#
Lead with a bold headline sentence that captures the panel’s whole purpose. Grafana truncates the hover-tooltip preview, so the lead has to carry the punch line on its own. After that, free-form prose covering nominal/anomalous framing and where to look next:
.description(
"**One-sentence headline of what this panel shows.** "
"Optional second sentence on why it exists. "
"Nominal: <expected state>. <Anomaly signal>: <what it means>. "
"If anomalous, check _Other Panel_ next."
)The four questions every description should try to answer:
- Why is this panel here? (operator-facing reason to care)
- What does nominal look like? (anchor expectations)
- What does anomalous look like? (the signal)
- What’s the next step? (cross-reference to another panel/tab)
Markdown conventions#
- Bold the first-sentence headline:
**Like this.** - Italics for cross-references between panels:
_Compute Objects -> Arrangements_ - Backticks for identifiers and code:
`mz_internal.mz_indexes`,`cluster_id` - Use ASCII
->in cross-references, not Unicode→. The arrow shows up in panel titles and descriptions;→triggers the ruffRUF001/RUF002“ambiguous character” lint rules. - Em-dash
—is OK inside description bodies and docstrings, but avoid it in titles (RUF001flags it in panel titles).
Cross-references#
Reference panels by their visible title, italicized, using -> between tab and panel when crossing tabs:
For per-pod CPU view see _Kubernetes Workloads -> Pod CPU Usage_.
Pair with _Sink Lag_ (in this tab) when investigating commit issues.Bare prose references are easier to follow than HTML/anchored links in the current dashboard ergonomics. Don’t include clickable URLs.
SQL drilldowns#
Where a panel surfaces a raw id (source_id, collection_id, sink_id), include the SQL to translate it to a user-friendly name:
Translate `collection_id` to a name via
`SELECT id, name FROM mz_internal.mz_indexes` (or `mz_materialized_views`).Per-variant descriptions for shared helpers#
When a single panel method is called multiple times with different parameters and each variant deserves its own description (e.g. Peek Latency at p50/p90/p99), define a module-level dict keyed by the variant label and have the helper look it up:
_PEEK_LATENCY_DESCRIPTIONS: dict[str, str] = {
"p50": "**Median read-query latency** — ...",
"p90": "**90th-percentile read-query latency** — ...",
"p99": "**Tail read-query latency** — ...",
}
def _peek_latency_panel(self, percentile: float, label: str):
...
.description(_PEEK_LATENCY_DESCRIPTIONS[label])Shared-helper / mixin descriptions#
When a helper function or mixin method registers the same panel on multiple tabs (e.g. cpu_total_panel from KubeResourcesMixin appears on both Summary and Kubernetes Workloads), the description is shared across all call sites. Either:
- Write a single description that’s accurate in both contexts (and call out the differences inline: “On Summary the monitoring exporter is excluded; on K8s it’s included.”)
- Refactor the helper to accept a
description=parameter and have each call site pass its own.
The shared-string approach is cheaper; refactor only when the descriptions truly need to diverge.
PromQL conventions#
Rate intervals#
Use [$__rate_interval] for rate() window selectors. Grafana derives this from the panel’s resolution so the rate window adapts to zoom level. Use a literal range ([5m], [1h]) only when the panel needs a specific window for semantic reasons — e.g. the “Current CPU Usage (5 min)” summary stat deliberately samples a 5-minute window regardless of zoom.
The datasource MUST declare the real scrape interval, or every
rate()panel silently renders empty. Grafana computes$__rate_interval = max($__interval + scrapeInterval, 4 × scrapeInterval), wherescrapeIntervalis the datasource’s configured “Scrape interval” (jsonData.timeInterval). Left unset it defaults to 15s, so$__rate_intervalcollapses to ~1m. If Prometheus actually scrapes every 60s, a 1-minute window contains a single sample andrate()returns nothing — the panel is blank even though the metric has data and traffic is flowing. Fix it at the datasource (one setting, fixes all panels), not per query:# grafana datasource provisioning (helm/terraform) datasources: - name: Prometheus type: prometheus jsonData: timeInterval: "60s" # MUST match Prometheus' real scrape_intervalKeep
timeIntervalin sync with the actualscrape_interval. Diagnose a suspected mismatch withcount_over_time(<metric>[1m])— if it returns1, the scrape interval is ≥60s and a[1m]rate window can’t compute. The per-panel “Min interval” (minStep) is a local override of the same value, but the datasource setting is the correct global fix.
Filtering cAdvisor metrics#
The inlined container filter (variables.container_filter('namespace=~"$mzNamespaceList"'), exposed as the CONTAINER_FILTER constant in k8s_resources.py) expands to namespace=~"$mzNamespaceList",container!="",container!="POD". This excludes the pod-network-namespace sentinel and the empty-container series cAdvisor reports for pod-level metrics.
That means don’t use CONTAINER_FILTER for container_network_* metrics — those are the pod-level metrics it excludes. For network queries, scope only with namespace=~"$mzNamespaceList" (plus pod regex matchers as needed).
Aggregation defaults#
- For per-container metrics that you want to see per-pod (CPU, memory), group by
(namespace, pod, container). - For network metrics, group by
(namespace, pod)— this also drops the per-interfacecardinality (most pods report at leasteth0+lo). - For environment-wide rollups, group only by
(namespace)or(container)as appropriate.
Series cardinality budgets#
Prefer aggregating away collection_id, replica_id, and worker_id on environment-wide panels unless a breakdown is the panel’s whole point. Large customer environments can have hundreds of collections multiplied by replicas multiplied by workers — keeping that cardinality has caused graphs to fail to load on production dashboards.
The dashboard default is per-cluster aggregation; specialists can drill down to specific collections via ad-hoc PromQL when needed. A working dashboard at less granularity is more valuable than a broken one with maximum detail.
Concretely:
sum by (instance_id)rather thansum by (instance_id, collection_id)max by (cluster, replica)rather than per-worker series, unless the whole point of the panel is worker drift / skew detection (e.g. the Dataflows “per worker” panel is intentionally per-worker; the aggregate Dataflow Count panel is not).- For “show me the worst offenders” panels, use
topk(N, …)rather than letting every series through.
Filtering by cluster / replica#
Materialize cluster pods follow the naming convention …-cluster-<cluster_id>-replica-<replica_id>-…. To make the mzClusterList and mzReplicaList selectors filter cluster pods without hiding system pods (envd, balancer, etc.), use two queries per panel with module-level regex constants:
CLUSTER_POD_RE = ".*-cluster-${mzClusterList:regex}-replica-${mzReplicaList:regex}-.*"
NONCLUSTER_POD_RE = ".*-cluster-.*-replica-.*"
# In an f-string query, `{CONTAINER_FILTER}` interpolates the inlined filter:
# Query 1 — cluster-replica pods, filtered by selection:
container_cpu_usage_seconds_total{{{CONTAINER_FILTER}, pod=~"{CLUSTER_POD_RE}"}}
# Query 2 — everything else, always shown:
container_cpu_usage_seconds_total{{{CONTAINER_FILTER}, pod!~"{NONCLUSTER_POD_RE}"}}Putting the regex constants at module scope (next to CADVISOR_MISSING etc.) keeps them shareable across all panels in the file and prevents drift between numerator and denominator patterns.
Deployment target: self-managed vs cloud#
The dashboards target self-managed Materialize. This is the single most important fact for choosing metrics and labels, and it was a late-breaking correction — the original assumptions (below, and in earlier git history) were written against Materialize Cloud and are wrong for self-managed:
- No
v2_mz_*metrics. The entirev2_mz_*family comes from the cloud-only promsql-exporter and is absent on self-managed. Always use themz_*metric exported by environmentd/clusterd directly. (This reverses the old “preferv2_mz_when both exist” guidance.) - No
materialize_cloud_organization_id. Environments are identified bymaterialize_cloud_organization_name(and the k8s namespace they run in,materialize_cloud_organization_namespace/kubernetes_namespace). The hex org id is cloud-only. - No
materialize_cloud_availability_zone. AZ/topology is a cloud concept; absent on self-managed. - No
cluster_environmentd_materialize_cloud_cluster_name/*_replica_name. The long-form id labels exist; their name companions do not — legend/group-by on the ids.
When verifying, query the live instance for what actually exists (list_prometheus_metric_names, list_prometheus_label_names) rather than trusting a remembered metric name.
Converging cloud and self-managed: the SQL metric prefix#
A subset of metrics is SQL-derived and differs between environments only by a name prefix: mz_X on self-managed (environmentd /metrics/mz_* endpoints) vs v2_mz_X in cloud (new-promsql-exporter).
To write one query that works in both, prefix those metric names with variables.SQL_METRIC_PREFIX.
The prefix is baked in at generation time, not resolved by Grafana at view time.
variables.SQL_METRIC_PREFIX is read from GLOBAL_DASHBOARD_CONFIG.sql_metric_prefix (py_mzmon_lib.config), which defaults to mz_.
This replaced an earlier $sqlMetricPrefix Grafana query variable that auto-detected the prefix by inspecting which …compute_cluster_status series existed — Google Managed Prometheus (GMP) can’t run that query_result(...) + regex auto-detection, so the prefix must be decided in Python and emitted as a literal metric name.
# In a Python f-string query:
f"{variables.SQL_METRIC_PREFIX}compute_cluster_status{{{variables.ENVIRONMENT_FILTER}}}"
# renders -> mz_compute_cluster_status{materialize_cloud_organization_name=~"$environmentIdList"}A v2_mz_ cloud variant is planned but not built yet.
It is selected by config (GLOBAL_DASHBOARD_CONFIG.sql_metric_prefix, settable via the SQL_METRIC_PREFIX env var), so flipping it regenerates every SQL-derived metric name.
Caveat: variables.SQL_METRIC_PREFIX is captured at module import, so a single process generates one prefix — produce the mz_ and v2_mz_ boards in separate invocations.
(Threading a build context through panel construction, so one process can emit both variants, is under consideration — see the open question at the end of this section.)
Only prefix SQL-derived metrics. Genuine instrumentation (timely/differential counters scraped from environmentd/clusterd /metrics) carries the same bare mz_ name in both environments — prefixing it produces v2_mz_… which doesn’t exist in cloud and breaks the panel.
- Prefix (SQL-derived):
compute_cluster_status, the catalog*_countmetrics (tables/views/mzd_views/indexes/sources/sinks/clusters/cluster_reps/connections/secrets/catalog_items),storage_objects,object_id,workload_clusters, the arrangement-introspection family (arrangement_record_count/_size_bytes/…),dataflow_elapsed_seconds_total,compute_replica_park_duration_seconds_total,compute_hydration_time_seconds. - Do NOT prefix (genuine):
arrangement_maintenance_seconds_total,compute_replica_history_dataflow_count,compute_peek_duration_seconds_*,source_*/sink_*throughput/lag/error metrics,query_total,adapter_commands,active_sessions/active_subscribes,compute_controller_hydration_queue_size,dataflow_wallclock_lag_seconds.
Quick test: a metric is genuine (don’t prefix) if it appears under the plain-mz_ name on the cloud materialize job; SQL-derived (prefix) if cloud only has it as v2_mz_.
Conventions:
- Interpolate
{variables.SQL_METRIC_PREFIX}into the query f-string (a metric prose name in a panel description stays the literal self-managed name — don’t substitute there). - Leave a one-line reference comment with the concrete names, e.g.
# mz_tables_count / v2_mz_tables_count, so the resolved names stay greppable. - In table transforms, the value-field name is the resolved metric, so
excludeByNamemust list bothmz_Xandv2_mz_X. - This is a convergence shim: once cloud’s
new-promsql-exporteris replaced by nativemz_instrumentation, the prefix collapses tomz_everywhere and the config knob retires.
Rendering and verifying generation-time substitutions#
When a change only rewrites how queries are generated (inlining a filter, baking the prefix) but should not change the rendered PromQL, verify it mechanically.
Render the dashboard before and after, apply the expected textual expansion to the baseline (e.g. ${sqlMetricPrefix} → mz_, $environmentFilter → materialize_cloud_organization_name=~"$environmentIdList"), and assert the query bodies are byte-identical and only the intended template variables were removed.
This catches f-string brace-escaping mistakes that lint and type-checks miss.
Materialize metric label families#
Materialize mz_* metrics come from two scraper paths with different label naming conventions. Picking the wrong filter is a common failure mode.
Short-form (envd-side and most metrics):
instance_id(this is the cluster id)replica_idreplica_full_name(=<cluster_name>.<replica_name>, e.g.quickstart.r1) — on some metrics; the only place a friendly cluster name appears on the data-plane metrics.
Examples: mz_dataflow_elapsed_seconds_total, mz_arrangement_record_count, mz_active_subscribes, mz_compute_controller_*, mz_query_total, mz_adapter_commands. Note mz_compute_peek_duration_seconds_* has instance_id but no replica_id (envd-side, per-cluster only).
Long-form (some clusterd-scraped metrics):
cluster_environmentd_materialize_cloud_cluster_idcluster_environmentd_materialize_cloud_replica_idcluster_environmentd_materialize_cloud_replica_rolecluster_environmentd_materialize_cloud_size/*_scale/*_workersworker_id
Examples: mz_arrangement_maintenance_seconds_total, mz_compute_replica_history_dataflow_count, and (expected, unverified — no sources/sinks in the test env) mz_source_* / mz_sink_*. The *_cluster_name / *_replica_name companions are absent on self-managed — legend and group-by on the *_cluster_id / *_replica_id labels instead.
Cluster/replica info metric: mz_compute_cluster_status is the richest — it carries compute_cluster_id, compute_cluster_name, compute_replica_id, compute_replica_name, size, and mz_version. It backs the cluster picker variable and the Cluster Information table.
Env-scoped counts with NO cluster labels: mz_tables_count, mz_views_count, mz_mzd_views_count (materialized views), mz_clusters_count, mz_cluster_reps_count, mz_active_subscribes. These get the ENV_SCOPED_NOTE callout in descriptions. No self-managed equivalent exists for source/sink/index counts or source/sink status (the cloud-only v2_mz_sources_count / v2_mz_sinks_count / v2_mz_indexes_count / v2_mz_source_status / v2_mz_production_object); panels that need them are kept with a TODO(self-managed) and a no_value.
Helper constants for filtering:
_COMPUTE_FILTER(instorage_objects.py) — long-form filter on env + cluster + replica (materialize_cloud_organization_name+cluster_environmentd_materialize_cloud_cluster_id/_replica_id)._ARRANGEMENT_FILTER(incompute_objects.py) — same shape, different module. Originally arrangement-specific, now reused for dataflows.- These two constants are the same PromQL fragment in two places; lifting them to a shared module is a known cleanup candidate.
Known metric quirks and gotchas#
Things that have surprised us during development; worth knowing before touching the relevant panels.
mz_overv2_mz_— always, on self-managed. Thev2_mz_*family does not exist here (see Deployment target). This reverses earlier guidance; treat anyv2_mz_*reference in old code or notes as a bug.- “Peek” is the read-query latency metric. No “query” in the name.
mz_compute_peek_duration_seconds_*is the histogram for read-query latency on indexed data (the differential-dataflow operation behindSELECT … FROM <view>). It is envd-side: it carriesinstance_idbut noreplica_id, so peek latency is per-cluster, not per-replica. mz_storage_objectsis the source/sink catalog metric. One series per (object, replica), value1, with labelsid,type(source/sink),object_type/connection_type(postgres/kafka/…),envelope_type,cluster_id,replica_id. It excludes the hidden<name>_progresssubsources, so it’s the right metric for counts and type breakdowns:count(group by (id) (mz_storage_objects{type="source"})). It carries no name and no status label.- Count metrics double-count progress subsources.
mz_sources_count/mz_sinks_countdo exist on self-managed (once a source/sink is created), but they fold the hidden<name>_progresssubsources into their per-typecounts (3 Postgres sources →type="postgres"=6). Usemz_storage_objectsfor accurate counts.mz_tables_count/mz_views_count/mz_mzd_views_count/mz_clusters_count/mz_cluster_reps_countare fine as-is. - Catalog
*_countmetrics only exist once an object of that type does.mz_sources_count,mz_sinks_count, andmz_indexes_countare absent from a fresh env and appear the moment you create the first source / sink / index — so a metric being missing doesn’t mean “no self-managed equivalent,” it can mean “none created yet.” Confirmed equivalents:mz_indexes_count(carries therelation_typebreakdown — table / view / materialized-view; sum over it thenmaxto dedup pods),mz_sources_count/mz_sinks_count(carrytype, but double-count progress subsources — prefermz_storage_objectsfor counts, see above).mz_tables_count/mz_views_count/mz_mzd_views_countare stable. - No source/sink status metric. The only
*_statusmetrics aremz_compute_cluster_status,mz_connection_status,mz_balancer_connection_status(the cloud-onlyv2_mz_source_statushas no equivalent). For running/stalled/errored, querymz_internal.mz_source_statuses/mz_sink_statusesin SQL. Metric-side health signals:mz_source_offset_commit_failures,mz_sink_rdkafka_txerrs/ connects / disconnects. - Hydration is SQL-only. No Prometheus metric exposes per-collection hydration state/time on self-managed:
v2_mz_compute_hydration_time_secondsis cloud-only, andmz_compute_controller_hydration_queue_sizeis the controller’s scheduling queue (drains fast — reads 0 even while 100+ objects are mid-hydration). Usemz_internal.mz_hydration_statuses(WHERE NOT hydrated) andmz_internal.mz_compute_hydration_timesin SQL. The metric-side proxy is frontier lag (below). mz_dataflow_wallclock_lag_secondsis the freshness signal — how far each collection’s output frontier trails real time. It’s a summary withquantile0(min) /1(max) only — take1for worst-case. It emits a u64::MAX sentinel (~1.8e19) for collections with no established frontier (idle / mid-hydration / not yet producing); filter with< 1e9or it blows out the axis. Carriescollection_id+instance_id+replica_id, but also a redundant series withoutinstance_id— addinstance_id!=""to dedup. Backs the Compute Objects -> Freshness row (the< 1e9filtered view = collections that have a frontier but trail real time). Collections with no frontier yet (mid-hydration / stuck) are the sentinel-valued ones filtered out here — they surface instead in the inverted> 1e15count (see next bullet).- An unreachable source upstream does NOT increment
mz_source_offset_commit_failures. That counter only fires when the upstream is reachable but rejects the commit. For a broker/DB that’s simply unreachable (BrokerTransportFailure, severed security group, DNS), the source never reaches the commit step, so commit-failures stays flat at 0 even though the source isstalled. The detector that works:offset_committed > offset_known. Normallyoffset_known >= offset_committed; when the upstream is unreachable the source can’t fetch metadata andoffset_knowncollapses belowoffset_committed. Usemax by (source_id) (offset_committed) > bool max by (source_id) (offset_known)for a per-source 0/1 “disconnected” flag (verified: stalled Kafka source -> 1, healthy Postgres sources -> 0). Sources have no transport-error counter the way sinks havemz_sink_rdkafka_txerrs, so this offset comparison is the closest metric-side “can’t reach upstream” signal. It backs the second series of the Storage -> Sources -> Source Upstream Errors panel. - Per-replica failures hide inside
sum by (source_id)aggregates. Replicas of a multi-replica cluster ingest independently; if one is restarted and can’t resume pulling (e.g. a stale Kafka connection), it silently reads 0 while its siblings keep going. The source still reportsRunning,mz_source_offset_commit_failuresstays 0 (it isn’t failing to commit, just not pulling), and an aggregate throughput panel looks fine because the healthy replicas carry the volume. The only metric-side tell is a per-replica breakdown —sum by (parent_source_id, cluster_environmentd_materialize_cloud_replica_id) (rate(mz_source_messages_received ...))— where the dead replica’s line drops to 0 (same idea as the per-worker dataflow skew panel). Frontier lag climbs in parallel. Lesson: for ingest/replica health, keep at least one per-replica panel rather than only the per-source rollup. - The wallclock-lag sentinel count is a hydration-queue proxy (and the closest thing to a hydration-state metric on self-managed). Inverting the freshness filter —
count(... mz_dataflow_wallclock_lag_seconds{quantile="1"} > 1e15)withinstance_id!=""— counts collections with no established frontier, i.e. still (re)building state. It spikes briefly on every replica restart and drains back to 0 — that’s normal (re)hydration, not breakage. A count that stays elevated is the genuinely-broken case (a collection that never hydrates, e.g. a source whoseCREATEdidn’t finish). It backs the Currently Hydrating stat (Summary + Compute -> Hydration) as a neutral sparkline — deliberately not alarm-colored, since brief spikes are expected; an earlier red “Stuck Objects” framing was dropped because alarm-on-any false-fired on routine restarts. Metrics carry onlycollection_id; resolve names / true status viamz_internal.mz_hydration_statuses WHERE NOT hydrated,mz_source_statuses/mz_sink_statuses, or the console Objects view. mz_source_bytes_received.source_idis the subsource id, not the primary. The primary lives inparent_source_id. Postgres sources fan out one bytes_received series per replicated table. Aggregate byparent_source_idto get per-primary rates. (No friendly-name join is available —v2_mz_source_statusis cloud-only — so the legend isparent_source_id.)- Storage metrics confirm the long-form label family.
mz_source_*/mz_sink_*usecluster_environmentd_materialize_cloud_cluster_id/_replica_id(verified live) — so_COMPUTE_FILTERis correct. Caveat: the$mzClusterListpicker is built frommz_compute_cluster_status(compute clusters only); a dedicated ingest cluster won’t appear there, so selecting a specific cluster can hide storage objects. Default “All” shows everything. mz_sink_oustanding_progress_recordsis misspelled in Materialize itself (“oustanding” not “outstanding”). Don’t “fix” the PromQL — match the metric name as-is.mz_compute_controller_subscribe_countvsmz_active_subscribestrade-off: the former hasinstance_id(cluster-filterable) but nosession_type; the latter hassession_typebut no cluster labels. The summary tab usesmz_active_subscribesfor the session_type donut, accepting the loss of cluster filtering.s2is themz_catalog_servercluster and dominates many panels (commit rates, peek counts, arrangement maintenance, hydration). It’s a system cluster and the noise floor is its business-as-usual. Mention this explicitly in panel descriptions where users might mistake it for an anomaly.- Duplicate
jobscrapes inflatesum(rate(...)). Some deployments run several Prometheus scrape jobs against the same clusterd:6878endpoint with different keep-rules, so a metric can appear under Njobvalues (observed:kubernetes-pods,kubernetes-pods-mz-{usage,compute,storage}). Confirmed multi-job:mz_source_*,mz_sink_*,mz_arrangement_*,mz_compute_replica_history_*— a plainsum(rate(...))over them reads N× the truth. Fix: wrap the inner counter/gauge inmax without (job) (...)before the outer aggregation (no-op when there’s one job).max by (...)panels andhistogram_quantileare already job-invariant. Do not exclude job names by pattern — the authoritative name varies by deployment, and on at least one instance several metrics (mz_compute_cluster_status,mz_storage_objects,mz_dataflow_elapsed_seconds_total, the*_countmetrics) live only on a “legacy” job, so an exclusion list blanks real panels. Pick the dedup label-set carefully:max without (job)keeps every other label; if a metric is also multi-scraped perinstance, addinstanceto thewithoutset.
PromQL recipes#
Reference for patterns we’ve established that aren’t obvious in the language docs.
Outer-join for label enrichment#
When one metric has the value you want and another has the friendly name, you can’t always inner-join (some entities may be missing from the name metric). Use a two-query outer-join:
# Named branch — series with a matching name available
(<value_query>
* on (<key>) group_left (<name_label>)
label_replace(<name_query>, "<key>", "$1", "<source_key>", "(.*)")) > 0
# Orphan branch — series without a name match
(<value_query>
unless on (<key>)
label_replace(<name_query>, "<key>", "$1", "<source_key>", "(.*)")) > 0Each branch goes into its own promql_query(...) in the panel; their legends can differ (e.g., {{source_name}} for the named branch and {{parent_source_id}} for the orphan). This pattern was used by _source_bytes_received_panel to enrich parent_source_id with source_name from v2_mz_source_status — but that status metric is cloud-only, so on self-managed the panel keeps just the parent_source_id aggregate (no name join). The recipe is still the right shape whenever a self-managed name metric is available.
Table pivot via groupingToMatrix#
To turn one row per (entity, dimension) into one row per entity with columns per dimension value (e.g., Success / Errors columns from a status label):
.transformation(... labelsToFields keepLabels=[entity, dimension])
.transformation(... merge)
.transformation(... groupingToMatrix
rowField=entity columnField=dimension valueField=Value)
.transformation(... organize renameByName={...})
.transformation(... sortBy ...)After groupingToMatrix, the row-identifier column comes out named <rowField>\<columnField> literally (one backslash). In Python source that’s "<rowField>\\<columnField>" (Python escape for one backslash). Real example: _adapter_commands_by_application_panel in connections_activity.py.
The naive alternative — two queries joined by joinByField — produces one Value column per input frame, not per query, which is N×M columns instead of 2. We tried that and gave up.
Histogram quantile aggregated by labels#
Standard pattern, but worth pinning the shape because the sum by labels matter:
histogram_quantile(0.99,
sum by (le, <preserved_labels...>) (
rate(<metric>_bucket{<filter>}[$__rate_interval])
)
)Real examples: _peek_latency_panel (per instance_id — the metric has no replica_id), _iceberg_commit_latency_panel (aggregated env-wide).
or vector(0) to keep panels non-empty#
For stat panels where “no series” should render as 0 rather than “No data”:
count(<series_query>) or vector(0)Real example: add_currently_hydrating_panel.
Per-cluster aggregation that handles label breakdowns#
To get a single env-wide count from a metric that may carry breakdown labels (like a type/size split), without falling for the “max grabs the biggest bucket, not the total” trap:
# authored as a Python f-string; the env filter is inlined, not a Grafana variable:
f"max(sum by (instance) (<metric>{{{variables.ENVIRONMENT_FILTER}}})) or vector(0)"sum by (instance) collapses all label dimensions per scraper instance, then max(...) dedups across multiple exporter pods if there’s more than one. Real example: _env_total_count_query in storage_objects.py (used for mz_tables_count; the source/sink count callers have no self-managed metric and read 0 via or vector(0)).
Cluster + non-cluster pod split#
For Kubernetes panels (CPU, memory, networking) where you want the cluster/replica selectors to scope cluster pods but not hide infra pods:
# authored as Python f-strings; CONTAINER_FILTER is inlined, not a Grafana variable:
# Cluster pods (filtered)
f'<metric>{{{CONTAINER_FILTER}, pod=~".*-cluster-${{mzClusterList:regex}}-replica-${{mzReplicaList:regex}}-.*"}}'
# Non-cluster pods (always shown)
f'<metric>{{{CONTAINER_FILTER}, pod!~".*-cluster-.*-replica-.*"}}'Constants CLUSTER_POD_RE and NONCLUSTER_POD_RE in k8s_resources.py hold the regex strings — reuse them rather than re-typing.
Shared module-level constants and helpers#
For navigation when looking for a shared building block:
| Where | Name | What it is |
|---|---|---|
dashboards/visualization.py | NO_FILTER_MATCH | “No matches for the current filters” string |
dashboards/visualization.py | PIE_LEGEND_BUILDER | piechart legend (table, right placement, value column) |
dashboards/visualization.py | TS_LEGEND_BUILDER | timeseries legend (table, bottom, Max/Avg/Last calcs) |
dashboards/visualization.py | sparkline_stat(shade=…) | stat.Visualization factory with area sparkline |
dashboards/palette.py | THEME_PALETTE (alias of BRIGHT_QUALITATIVE_NONSEQ) | tab-level theme colors, 7 entries |
dashboards/palette.py | INCANDESC_SEQUENTIAL, Binary, TriHealth, SUNSET_* | health/threshold palettes |
dashboards/threshold.py | health_mapping, health_thresholds | text + color mapping for healthy/degraded/unhealthy |
dashboards/threshold.py | time_stable_thresholds(seconds=…) | gray-out for “long ago is fine” |
dashboards/threshold.py | error_thresholds(max_errors=…) | gradient for error-count panels |
dashboards/threshold.py | load_thresholds(max_load=…) | gradient for load gauges |
k8s_resources.py | CADVISOR_MISSING, KSM_MISSING | no-value strings for cadvisor / kube-state-metrics gaps |
k8s_resources.py | CLUSTER_POD_RE, NONCLUSTER_POD_RE | pod-name regex matchers for cluster filtering |
compute_objects.py | ARRANGEMENT_LABEL_* constants + _ARRANGEMENT_FILTER | long-form cluster label names + filter snippet |
compute_objects.py | ENV_SCOPED_NOTE | “Environment-scoped — not affected by…” boilerplate |
connections_activity.py | _PEEK_LATENCY_DESCRIPTIONS | per-percentile (p50/p90/p99) description dict for the peek-latency panels |
storage_objects.py | _COMPUTE_FILTER | long-form filter snippet (duplicate of _ARRANGEMENT_FILTER) |
storage_objects.py | ENV_SCOPED_NOTE | duplicate of the one in compute_objects.py — consolidation candidate |
compute_objects.py | add_currently_hydrating_panel(dashboard, panel_id, shade=…) | shared panel factory used by Summary’s Environment Health row |