From 7a3b6c109501eef26dc02c63ca167bb086cbc40b Mon Sep 17 00:00:00 2001 From: Pavel K Date: Mon, 6 Jul 2026 23:37:54 +0200 Subject: [PATCH] stat_replication: fix "slot_name does not exist" by joining pg_replication_slots The stat_replication collector currently fails every scrape with: pq: column "slot_name" does not exist slot_name is not a column of pg_stat_replication on any supported PostgreSQL version (verified on PG 17.10.0 Docker, PG 18 Docker, and the official PG 17 and PG 18 docs). It lives on pg_replication_slots and has to be pulled in via a JOIN on active_pid = pid. Before #1306 the same collector ran via the legacy yaml-driven exporter, which used SELECT * and silently produced an empty slot_name label when the column was missing. #1306 promoted slot_name into an explicit SQL column list, turning that silent no-op into a hard parse-time error. CI did not catch it because the unit tests use sqlmock and do not validate against the real Postgres schema. stat_replication is defaultEnabled, so every user on main after #1306 sees the collector dead on every scrape, regardless of whether they run Aurora or vanilla Postgres. Fix: LEFT JOIN pg_replication_slots s ON s.active_pid = pg_stat_replication.pid and read s.slot_name. The label is populated when a named slot is in use, empty otherwise (matching the previous yaml behavior). Since pg_replication_slots.active_pid only exists on PostgreSQL 9.5+, versions 9.2-9.4 fall back to a query without the join and leave the slot_name label empty, matching the legacy yaml behavior. Verified against live postgres:9.4, 9.6 and 17 (with a streaming standby on a named slot) in Docker. Fixes #1310. Signed-off-by: Pavel K --- CHANGELOG.md | 3 +- collector/pg_stat_replication.go | 54 +++++++++++++++++++++++++-- collector/pg_stat_replication_test.go | 49 ++++++++++++++++++++++++ 3 files changed, 102 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 984c9ae57..ef2e4e0c2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,7 @@ ## main / (unreleased) +* [BUGFIX] stat_replication: fix "slot_name does not exist" by joining `pg_replication_slots` (closes #1310) + ## 0.20.0 / 2026-06-29 BREAKING CHANGES: @@ -44,7 +46,6 @@ its `--no-collector.*` flag. * [BUGFIX] Fix duplicate `pg_stat_progress_vacuum` metrics for cross-database vacuums by @steinbrueckri in https://github.com/prometheus-community/postgres_exporter/pull/1262 * [BUGFIX] Fix `pg_settings` collection when `short_desc` is NULL by @jun-gradial in https://github.com/prometheus-community/postgres_exporter/pull/1327 - ## 0.19.1 / 2026-02-25 * [CHANGE] Filter and warn about duplicates in pg_stat_statements (#998) by @Nudin in https://github.com/prometheus-community/postgres_exporter/pull/1259 diff --git a/collector/pg_stat_replication.go b/collector/pg_stat_replication.go index 816f93f60..6f10c5557 100644 --- a/collector/pg_stat_replication.go +++ b/collector/pg_stat_replication.go @@ -55,15 +55,20 @@ var ( prometheus.Labels{}, ) + // slot_name is not a column of pg_stat_replication; it lives on + // pg_replication_slots and is linked via the standby's WAL sender + // PID. LEFT JOIN so clients that do not use a named slot still get + // a row with an empty slot_name label. statReplicationQuery = ` SELECT application_name, client_addr::text, state, - slot_name, + s.slot_name, (case pg_is_in_recovery() when 't' then pg_wal_lsn_diff(pg_last_wal_receive_lsn(), pg_lsn('0/0'))::float else pg_wal_lsn_diff(pg_current_wal_lsn(), pg_lsn('0/0'))::float end) AS pg_current_wal_lsn_bytes, (case pg_is_in_recovery() when 't' then pg_wal_lsn_diff(pg_last_wal_receive_lsn(), replay_lsn)::float else pg_wal_lsn_diff(pg_current_wal_lsn(), replay_lsn)::float end) AS pg_wal_lsn_diff FROM pg_stat_replication + LEFT JOIN pg_replication_slots s ON s.active_pid = pg_stat_replication.pid ` statReplicationQueryBefore10 = ` @@ -71,7 +76,20 @@ var ( application_name, client_addr::text, state, - slot_name, + s.slot_name, + (case pg_is_in_recovery() when 't' then pg_xlog_location_diff(pg_last_xlog_receive_location(), replay_location)::float else pg_xlog_location_diff(pg_current_xlog_location(), replay_location)::float end) AS pg_xlog_location_diff + FROM pg_stat_replication + LEFT JOIN pg_replication_slots s ON s.active_pid = pg_stat_replication.pid + ` + + // pg_replication_slots.active_pid only exists on PostgreSQL 9.5+, so + // on older versions slot_name cannot be resolved; the label is left + // empty (matching the legacy yaml exporter behavior). + statReplicationQueryBefore95 = ` + SELECT + application_name, + client_addr::text, + state, (case pg_is_in_recovery() when 't' then pg_xlog_location_diff(pg_last_xlog_receive_location(), replay_location)::float else pg_xlog_location_diff(pg_current_xlog_location(), replay_location)::float end) AS pg_xlog_location_diff FROM pg_stat_replication ` @@ -81,8 +99,10 @@ func (PGStatReplicationCollector) Update(ctx context.Context, instance *instance switch { case instance.version.GTE(semver.MustParse("10.0.0")): return updateStatReplication(ctx, instance, ch) - case instance.version.GTE(semver.MustParse("9.2.0")): + case instance.version.GTE(semver.MustParse("9.5.0")): return updateStatReplicationBefore10(ctx, instance, ch) + case instance.version.GTE(semver.MustParse("9.2.0")): + return updateStatReplicationBefore95(ctx, instance, ch) default: return nil } @@ -153,6 +173,34 @@ func updateStatReplicationBefore10(ctx context.Context, instance *instance, ch c return rows.Err() } +func updateStatReplicationBefore95(ctx context.Context, instance *instance, ch chan<- prometheus.Metric) error { + db := instance.getDB() + rows, err := db.QueryContext(ctx, statReplicationQueryBefore95) + if err != nil { + return err + } + defer rows.Close() + + for rows.Next() { + var applicationName, clientAddr, state sql.NullString + var xlogLocationDiff sql.NullFloat64 + if err := rows.Scan(&applicationName, &clientAddr, &state, &xlogLocationDiff); err != nil { + return err + } + + if xlogLocationDiff.Valid { + ch <- prometheus.MustNewConstMetric( + statReplicationXlogLocationDiffDesc, + prometheus.GaugeValue, + xlogLocationDiff.Float64, + statReplicationLabelValues(applicationName, clientAddr, state, sql.NullString{})..., + ) + } + } + + return rows.Err() +} + func statReplicationLabelValues(applicationName, clientAddr, state, slotName sql.NullString) []string { return []string{ nullStringValue(applicationName), diff --git a/collector/pg_stat_replication_test.go b/collector/pg_stat_replication_test.go index 60917c0a9..0816a0ca0 100644 --- a/collector/pg_stat_replication_test.go +++ b/collector/pg_stat_replication_test.go @@ -123,6 +123,55 @@ func TestPGStatReplicationCollectorBefore10(t *testing.T) { } } +func TestPGStatReplicationCollectorBefore95(t *testing.T) { + db, mock, err := sqlmock.New() + if err != nil { + t.Fatalf("Error opening a stub db connection: %s", err) + } + defer db.Close() + + inst := &instance{db: db, version: semver.MustParse("9.4.0")} + + rows := sqlmock.NewRows([]string{ + "application_name", + "client_addr", + "state", + "pg_xlog_location_diff", + }).AddRow("standby", "10.0.0.1", "streaming", 32.0) + mock.ExpectQuery(sanitizeQuery(statReplicationQueryBefore95)).WillReturnRows(rows) + + ch := make(chan prometheus.Metric) + go func() { + defer close(ch) + c := PGStatReplicationCollector{} + if err := c.Update(context.Background(), inst, ch); err != nil { + t.Errorf("Error calling PGStatReplicationCollector.Update: %s", err) + } + }() + + expected := []MetricResult{ + { + labels: labelMap{ + "application_name": "standby", + "client_addr": "10.0.0.1", + "state": "streaming", + "slot_name": "", + }, + value: 32, + metricType: dto.MetricType_GAUGE, + }, + } + convey.Convey("Metrics comparison", t, func() { + for _, expect := range expected { + m := readMetric(<-ch) + convey.So(expect, convey.ShouldResemble, m) + } + }) + if err := mock.ExpectationsWereMet(); err != nil { + t.Errorf("there were unfulfilled exceptions: %s", err) + } +} + func TestPGStatReplicationCollectorNullValues(t *testing.T) { db, mock, err := sqlmock.New() if err != nil {