Skip to content

Commit bd2f16c

Browse files
committed
fix(metrics): rank don't filter — keep pg_catalog/pg_toast/timescale visible
The first revision read from pg_stat_user_*/pg_statio_user_*, which the Postgres views define as 'pg_stat_all_* WHERE schemaname NOT IN (pg_catalog, information_schema) AND schemaname !~ ^pg_toast'. That's identity-based filtering wearing a different hat: it silently hides bloat in pg_toast, hot scans in pg_catalog, and any issue inside _timescaledb_internal. If a TOAST table is bloated or a catalog index is being hammered, the operator wouldn't see it. Rework the four metrics to read pg_stat_all_*/pg_statio_all_* directly and rely PURELY on cardinality control: - Top 100 by relevance per database (idx_scan / pg_total_relation_size / heap_blks_read / idx_blks_read). - Tail aggregated into a single 'other' row so totals stay correct. - No pg_temp%, no pg_toast%, no _timescaledb% schema filtering anywhere. A relation enters the top-N by activity or by size; if it's not in the top-N, it's in 'other'. The only WHERE filter kept is the zero-counter row skip on the two statio metrics — those rows literally carry no information (every gauge is 0) and cannot mask any issue, so dropping them is information-preserving, not identity-based. Smoke-tested against PG16: - pg_stat_all_tables: 101 rows, 75 from pg_catalog/etc. in top-100. - pg_stat_all_indexes: 101 rows, 98 from system schemas. - pg_statio_all_tables / pg_statio_all_indexes: catalog/toast rows appear in top-N once they have any I/O. Regression tests updated to assert: reads pg_stat_all_*/pg_statio_all_*, no schemaname/nspname LIKE patterns, no 'pg_toast'/'pg_catalog'/ '_timescaledb' literals — top-N + 'other' is the only mechanism.
1 parent 10c153e commit bd2f16c

2 files changed

Lines changed: 73 additions & 50 deletions

File tree

config/pgwatch-prometheus/metrics.yml

Lines changed: 41 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -1552,13 +1552,15 @@ metrics:
15521552
- total_relation_size_bytes
15531553
statement_timeout_seconds: 15
15541554
pg_stat_all_indexes:
1555-
# Top-N + "other" bucket pattern ported from pgwatch2 postgres.ai edition
1556-
# (gitlab.com/postgres-ai/pgwatch2 — our fork of Cybertec's pgwatch2,
1557-
# used as gen2 of our monitoring stack before postgresai). Reads
1558-
# pg_stat_user_indexes so pg_catalog/information_schema/pg_toast are
1559-
# excluded by the Postgres view itself, no hand-curated nspname pattern.
1560-
# The "other" row aggregates the tail so totals stay correct under a
1561-
# hard cardinality cap.
1555+
# Bound cardinality by ranking — NOT by identity. Reads pg_stat_all_indexes
1556+
# directly (NOT pg_stat_user_indexes) so pg_catalog, pg_toast and
1557+
# _timescaledb_internal indexes stay visible: a heavily-scanned catalog
1558+
# index or a hot Timescale chunk index will naturally rank into the
1559+
# top-N. Everything below the cap is aggregated into a single `'other'`
1560+
# row so dashboard totals stay correct. Pattern adapted from pgwatch2
1561+
# postgres.ai edition (gitlab.com/postgres-ai/pgwatch2 — our fork of
1562+
# Cybertec's pgwatch2), but without that edition's pg_temp%/user-view
1563+
# filters which would silently hide system-schema problems.
15621564
sqls:
15631565
11: |
15641566
with ranked as ( /* pgwatch_generated */
@@ -1570,8 +1572,7 @@ metrics:
15701572
idx_scan,
15711573
idx_tup_read,
15721574
idx_tup_fetch
1573-
from pg_stat_user_indexes
1574-
where not schemaname like E'pg\\_temp%'
1575+
from pg_stat_all_indexes
15751576
)
15761577
select
15771578
current_database() as tag_datname,
@@ -1601,11 +1602,15 @@ metrics:
16011602
- idx_tup_fetch
16021603
statement_timeout_seconds: 15
16031604
pg_stat_all_tables:
1604-
# Top-N + "other" bucket pattern ported from pgwatch2 postgres.ai edition
1605-
# (gitlab.com/postgres-ai/pgwatch2). Ranks by
1606-
# pg_total_relation_size — large tables are usually the interesting ones,
1607-
# which avoids starving big-but-static tables out of the top-N (the old
1608-
# n_live_tup+n_dead_tup ordering did exactly that).
1605+
# Bound cardinality by ranking — NOT by identity. Reads pg_stat_all_tables
1606+
# directly (NOT pg_stat_user_tables) so pg_catalog, pg_toast and
1607+
# _timescaledb_internal tables stay visible: a bloated TOAST table or a
1608+
# huge Timescale chunk will naturally rank into the top-N by
1609+
# pg_total_relation_size. Everything below the cap is summed into a
1610+
# single `'other'` row.
1611+
#
1612+
# Ordering by total relation size (vs the previous n_live_tup+n_dead_tup)
1613+
# keeps big-but-static tables — including pg_toast — in scope.
16091614
sqls:
16101615
11: |
16111616
with ranked as ( /* pgwatch_generated */
@@ -1631,7 +1636,7 @@ metrics:
16311636
autovacuum_count,
16321637
analyze_count,
16331638
autoanalyze_count
1634-
from pg_stat_user_tables
1639+
from pg_stat_all_tables
16351640
)
16361641
select
16371642
current_database() as tag_datname,
@@ -2961,13 +2966,16 @@ metrics:
29612966
statement_timeout_seconds: 15
29622967
pg_statio_all_tables:
29632968
description: >
2964-
Retrieves table-level I/O statistics from `pg_statio_user_tables`, returning
2965-
block-level read and hit counters for heap, index, TOAST and TOAST-index pages.
2966-
Ports the top-N + `'other'` bucket pattern from pgwatch2 postgres.ai
2967-
edition (gitlab.com/postgres-ai/pgwatch2): ranks tables by
2968-
heap_blks_read, keeps the top 100, and folds the tail into a single `'other'`
2969-
row so totals remain accurate while cardinality stays bounded. Drops rows
2970-
with no I/O activity at all (every counter zero).
2969+
Retrieves table-level I/O statistics from `pg_statio_all_tables`, returning
2970+
block-level read and hit counters for heap, index, TOAST and TOAST-index
2971+
pages. Adapts the top-N + `'other'` bucket pattern from pgwatch2 postgres.ai
2972+
edition (gitlab.com/postgres-ai/pgwatch2): ranks tables by heap_blks_read,
2973+
keeps the top 100, and folds the tail into a single `'other'` row so totals
2974+
remain accurate while cardinality stays bounded.
2975+
Reads pg_statio_all_tables (not pg_statio_user_tables) so I/O on pg_catalog,
2976+
pg_toast and _timescaledb_internal stays visible — those tables enter the
2977+
top-N by activity, not by schema membership. The zero-counter row skip is
2978+
kept (those rows literally carry no information and are not identity-based).
29712979
Compatible with all PostgreSQL versions.
29722980
sqls:
29732981
11: |-
@@ -2984,7 +2992,7 @@ metrics:
29842992
toast_blks_hit,
29852993
tidx_blks_read,
29862994
tidx_blks_hit
2987-
from pg_statio_user_tables
2995+
from pg_statio_all_tables
29882996
where
29892997
heap_blks_read > 0 or heap_blks_hit > 0
29902998
or idx_blks_read > 0 or idx_blks_hit > 0
@@ -3028,12 +3036,15 @@ metrics:
30283036
statement_timeout_seconds: 15
30293037
pg_statio_all_indexes:
30303038
description: >
3031-
Retrieves index-level I/O statistics from `pg_statio_user_indexes`, returning
3032-
block-level read and hit counters per index. Ports the pgwatch2
3033-
postgres.ai edition (gitlab.com/postgres-ai/pgwatch2)
3034-
top-N + `'other'` bucket pattern: ranks indexes by idx_blks_read, keeps the
3039+
Retrieves index-level I/O statistics from `pg_statio_all_indexes`, returning
3040+
block-level read and hit counters per index. Adapts the top-N + `'other'`
3041+
bucket pattern from pgwatch2 postgres.ai edition
3042+
(gitlab.com/postgres-ai/pgwatch2): ranks indexes by idx_blks_read, keeps the
30353043
top 100, folds the tail into a single `'other'` row, and drops indexes with
3036-
no I/O activity. Filters temp schemas.
3044+
no I/O activity (zero-counter rows carry no information).
3045+
Reads pg_statio_all_indexes (not pg_statio_user_indexes) so catalog,
3046+
pg_toast and _timescaledb_internal indexes stay visible: a hot catalog
3047+
index will rank into the top-N by activity, not be hidden by schema name.
30373048
Compatible with all PostgreSQL versions.
30383049
sqls:
30393050
11: |-
@@ -3045,10 +3056,8 @@ metrics:
30453056
indexrelname,
30463057
idx_blks_read,
30473058
idx_blks_hit
3048-
from pg_statio_user_indexes
3049-
where
3050-
not schemaname like E'pg\\_temp%'
3051-
and (idx_blks_read > 0 or idx_blks_hit > 0)
3059+
from pg_statio_all_indexes
3060+
where idx_blks_read > 0 or idx_blks_hit > 0
30523061
)
30533062
select
30543063
(extract(epoch from now()) * 1e9)::int8 as epoch_ns,

tests/compliance_vectors/test_mr219_monitoring_guards.py

Lines changed: 32 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -81,43 +81,57 @@ def test_pgwatch_metrics_yml_pg_stat_statements_has_top_n_filter():
8181

8282

8383
def test_pgwatch_stat_views_use_topn_and_other_bucket():
84-
"""High-cardinality per-relation metrics must port the pattern from
85-
pgwatch2 postgres.ai edition (gitlab.com/postgres-ai/pgwatch2, our
86-
fork of Cybertec's pgwatch2 used as the previous generation of our
87-
monitoring stack): read pg_stat_user_*/pg_statio_user_* (so pg_catalog,
88-
information_schema and pg_toast are excluded by the Postgres view
89-
itself, no hand-curated nspname pattern), keep the top 100 by relevance,
90-
and aggregate the tail into a single `'other'` tag row so dashboard
91-
totals stay correct under a hard cardinality cap. Hand-rolled nspname
92-
LIKE filters or LIMIT-only truncation silently drop the tail and break
93-
sums on extension-heavy or schema-heavy databases.
84+
"""High-cardinality per-relation metrics must bound cardinality by
85+
RANKING, not by IDENTITY. Read pg_stat_all_*/pg_statio_all_* directly
86+
(NOT the pg_stat_user_*/pg_statio_user_* views, which silently exclude
87+
pg_catalog/pg_toast and would hide bloat or hot scans in those
88+
relations), keep the top 100 by relevance, and aggregate the tail into
89+
a single `'other'` tag row so dashboard totals stay correct.
90+
91+
The principle: a bloated pg_toast or a heavy _timescaledb_internal
92+
chunk should appear in the top-N when its activity/size warrants it.
93+
Schema-name filtering (`pg_stat_user_*` views, `NOT LIKE 'pg_toast%'`,
94+
`NOT LIKE '_timescaledb%'`) makes those issues invisible. Hand-rolled
95+
nspname LIKE filters or LIMIT-only truncation likewise silently drop
96+
the tail and break sums on extension-heavy or schema-heavy databases.
9497
"""
9598
metrics = yaml.safe_load(
9699
(PROJECT_ROOT / "config/pgwatch-prometheus/metrics.yml").read_text()
97100
)
98101
expectations = {
99-
"pg_stat_all_indexes": "pg_stat_user_indexes",
100-
"pg_stat_all_tables": "pg_stat_user_tables",
101-
"pg_statio_all_tables": "pg_statio_user_tables",
102-
"pg_statio_all_indexes": "pg_statio_user_indexes",
102+
"pg_stat_all_indexes": "pg_stat_all_indexes",
103+
"pg_stat_all_tables": "pg_stat_all_tables",
104+
"pg_statio_all_tables": "pg_statio_all_tables",
105+
"pg_statio_all_indexes": "pg_statio_all_indexes",
103106
}
104107
for metric_name, base_view in expectations.items():
105108
for sql in metrics["metrics"][metric_name]["sqls"].values():
106109
compact_sql = _compact_sql(sql)
107-
assert base_view in compact_sql, metric_name
110+
# Reads the _all_ view, not the _user_ view — keeps catalog/toast/timescale visible.
111+
assert f"from {base_view}" in compact_sql, metric_name
112+
user_view = base_view.replace("_all_", "_user_")
113+
assert user_view not in compact_sql, metric_name
108114
# Top-N window + tail aggregation
109115
assert "row_number() over" in compact_sql, metric_name
110116
assert "rownum <= 100" in compact_sql, metric_name
111117
assert "rownum > 100" in compact_sql, metric_name
112118
assert "'other'" in compact_sql, metric_name
113119
# No unfiltered LIMIT-only truncation left in place
114120
assert "limit 5000" not in compact_sql, metric_name
121+
# No identity-based schema exclusions sneaking back in.
122+
assert "schemaname like" not in compact_sql, metric_name
123+
assert "nspname like" not in compact_sql, metric_name
124+
assert "'pg_toast'" not in compact_sql, metric_name
125+
assert "'pg_catalog'" not in compact_sql, metric_name
126+
assert "_timescaledb" not in compact_sql, metric_name
115127

116128

117129
def test_pgwatch_statio_skips_zero_activity_rows():
118-
"""pg_statio_user_* tail is mostly zero-I/O rows on schema-heavy DBs.
119-
Filtering them out (pgwatch2 behavior) cuts cardinality before the
120-
top-N cap is even reached and keeps the `'other'` bucket meaningful.
130+
"""pg_statio tail is mostly zero-I/O rows on schema-heavy DBs. Skipping
131+
them cuts cardinality before the top-N cap is even reached and keeps
132+
the `'other'` bucket meaningful. This is NOT identity-based filtering:
133+
a row with every counter zero literally carries no information and
134+
cannot mask any issue.
121135
"""
122136
metrics = yaml.safe_load(
123137
(PROJECT_ROOT / "config/pgwatch-prometheus/metrics.yml").read_text()

0 commit comments

Comments
 (0)