From e9c27a5fb73d3f2e5a58653baaf6de82e80f2779 Mon Sep 17 00:00:00 2001 From: Pavel K Date: Mon, 19 Oct 2026 04:53:36 +0200 Subject: [PATCH] Add aurora_stat_logical_wal_cache collector (#1414) * Add aurora_stat_logical_wal_cache collector for Amazon Aurora PostgreSQL Expose WAL cache hits, misses and read requests of each logical replication slot from aurora_stat_logical_wal_cache(). The function only exists in Aurora PostgreSQL 11.17+, 12.12+, 13.8+, 14.7+ and 15.2+. Checking for Aurora alone would make the collector fail on every scrape on older versions, so it first checks that the function exists with to_regproc, the same way Aurora itself is detected, and returns no data when it doesn't. The check is a small helper that other version-dependent Aurora collectors can reuse. The collector is disabled by default and returns no data on non-Aurora servers. Aurora collector names get their own block in config.go, so gofmt does not realign the main list when longer names are added. Signed-off-by: Pavel K * collector: rename auroraFunctionExists to functionExists The helper isn't Aurora specific, so it could be used by collectors for other types of instances as well. Signed-off-by: Pavel K --------- Signed-off-by: Pavel K --- CHANGELOG.md | 1 + README.md | 3 + collector/aurora_stat_logical_wal_cache.go | 114 +++++++++++++ .../aurora_stat_logical_wal_cache_test.go | 151 ++++++++++++++++++ collector/function_exists.go | 33 ++++ collector/function_exists_test.go | 50 ++++++ config/config.go | 10 +- docs/configuration.md | 1 + 8 files changed, 359 insertions(+), 4 deletions(-) create mode 100644 collector/aurora_stat_logical_wal_cache.go create mode 100644 collector/aurora_stat_logical_wal_cache_test.go create mode 100644 collector/function_exists.go create mode 100644 collector/function_exists_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 6f6d0628..d6e9b6f7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -34,6 +34,7 @@ the `-file` flags at a mounted secret. * [FEATURE] Export `pg_settings.pending_restart` as `pg_settings_pending_restart{name}` from the `settings` collector by @MarcWort in https://github.com/prometheus-community/postgres_exporter/pull/1396 * [FEATURE] Add `aurora_replica_status` collector for Amazon Aurora PostgreSQL by @megative in https://github.com/prometheus-community/postgres_exporter/pull/1311 * [FEATURE] Add `aurora_global_db_status` collector for Amazon Aurora PostgreSQL by @megative in https://github.com/prometheus-community/postgres_exporter/pull/1410 +* [FEATURE] Add `aurora_stat_logical_wal_cache` collector for Amazon Aurora PostgreSQL by @megative in https://github.com/prometheus-community/postgres_exporter/pull/1414 ## 0.20.1 / 2026-07-07 diff --git a/README.md b/README.md index 06e31cf4..e09e7a0d 100644 --- a/README.md +++ b/README.md @@ -109,6 +109,9 @@ This will build the docker image as `prometheuscommunity/postgres_exporter:${bra * `[no-]collector.aurora_replica_status` Enable the `aurora_replica_status` collector (default: disabled). +* `[no-]collector.aurora_stat_logical_wal_cache` + Enable the `aurora_stat_logical_wal_cache` collector (default: disabled). + * `[no-]collector.database` Enable the `database` collector (default: enabled). diff --git a/collector/aurora_stat_logical_wal_cache.go b/collector/aurora_stat_logical_wal_cache.go new file mode 100644 index 00000000..69b4107e --- /dev/null +++ b/collector/aurora_stat_logical_wal_cache.go @@ -0,0 +1,114 @@ +// Copyright The Prometheus Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package collector + +import ( + "context" + "database/sql" + + "github.com/prometheus/client_golang/prometheus" +) + +const auroraStatLogicalWalCacheSubsystem = "aurora_stat_logical_wal_cache" + +func init() { + registerCollector(auroraStatLogicalWalCacheSubsystem, serverScope, NewAuroraStatLogicalWalCacheCollector) +} + +// AuroraStatLogicalWalCacheCollector exposes metrics from Amazon Aurora +// PostgreSQL's aurora_stat_logical_wal_cache() function: WAL cache usage of +// each logical replication slot. It reports no data on servers that are not +// Aurora, or on Aurora versions without the function. +type AuroraStatLogicalWalCacheCollector struct{} + +func NewAuroraStatLogicalWalCacheCollector(collectorConfig) (Collector, error) { + return &AuroraStatLogicalWalCacheCollector{}, nil +} + +var ( + auroraStatLogicalWalCacheHits = prometheus.NewDesc( + prometheus.BuildFQName(namespace, auroraStatLogicalWalCacheSubsystem, "cache_hits_total"), + "Number of WAL cache hits for the replication slot.", + []string{"slot_name"}, nil, + ) + auroraStatLogicalWalCacheMisses = prometheus.NewDesc( + prometheus.BuildFQName(namespace, auroraStatLogicalWalCacheSubsystem, "cache_misses_total"), + "Number of WAL cache misses for the replication slot.", + []string{"slot_name"}, nil, + ) + auroraStatLogicalWalCacheBlocksRead = prometheus.NewDesc( + prometheus.BuildFQName(namespace, auroraStatLogicalWalCacheSubsystem, "blocks_read_total"), + "Number of WAL cache read requests for the replication slot.", + []string{"slot_name"}, nil, + ) + + auroraStatLogicalWalCacheQuery = `SELECT + name, + cache_hit, + cache_miss, + blks_read + FROM aurora_stat_logical_wal_cache()` +) + +func (c AuroraStatLogicalWalCacheCollector) Update(ctx context.Context, instance *instance, ch chan<- prometheus.Metric) error { + if !instance.isAurora { + return ErrNoData + } + db := instance.getDB() + + // aurora_stat_logical_wal_cache() is only in Aurora PostgreSQL 11.17+, + // 12.12+, 13.8+, 14.7+ and 15.2+. + exists, err := functionExists(ctx, db, "aurora_stat_logical_wal_cache") + if err != nil { + return err + } + if !exists { + return ErrNoData + } + + rows, err := db.QueryContext(ctx, auroraStatLogicalWalCacheQuery) + if err != nil { + return err + } + defer rows.Close() + + var found bool + for rows.Next() { + found = true + + var slotName string + var cacheHit, cacheMiss, blksRead sql.NullInt64 + + if err := rows.Scan(&slotName, &cacheHit, &cacheMiss, &blksRead); err != nil { + return err + } + + if cacheHit.Valid { + ch <- prometheus.MustNewConstMetric(auroraStatLogicalWalCacheHits, prometheus.CounterValue, int64CounterValue(cacheHit, instance.wrapLargeCounters), slotName) + } + if cacheMiss.Valid { + ch <- prometheus.MustNewConstMetric(auroraStatLogicalWalCacheMisses, prometheus.CounterValue, int64CounterValue(cacheMiss, instance.wrapLargeCounters), slotName) + } + if blksRead.Valid { + ch <- prometheus.MustNewConstMetric(auroraStatLogicalWalCacheBlocksRead, prometheus.CounterValue, int64CounterValue(blksRead, instance.wrapLargeCounters), slotName) + } + } + if err := rows.Err(); err != nil { + return err + } + if !found { + return ErrNoData + } + return nil +} diff --git a/collector/aurora_stat_logical_wal_cache_test.go b/collector/aurora_stat_logical_wal_cache_test.go new file mode 100644 index 00000000..ca679930 --- /dev/null +++ b/collector/aurora_stat_logical_wal_cache_test.go @@ -0,0 +1,151 @@ +// Copyright The Prometheus Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package collector + +import ( + "context" + "testing" + + "github.com/DATA-DOG/go-sqlmock" + "github.com/prometheus/client_golang/prometheus" + dto "github.com/prometheus/client_model/go" + "github.com/smartystreets/goconvey/convey" +) + +var auroraStatLogicalWalCacheColumns = []string{ + "name", + "cache_hit", + "cache_miss", + "blks_read", +} + +func TestAuroraStatLogicalWalCacheCollector(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, isAurora: true} + + expectFunctionExists(mock, "aurora_stat_logical_wal_cache", true) + rows := sqlmock.NewRows(auroraStatLogicalWalCacheColumns). + AddRow("test_slot1", 24, 0, 24). + AddRow("test_slot2", 1, 2, 3) + mock.ExpectQuery(sanitizeQuery(auroraStatLogicalWalCacheQuery)).WillReturnRows(rows) + + ch := make(chan prometheus.Metric) + go func() { + defer close(ch) + c := AuroraStatLogicalWalCacheCollector{} + if err := c.Update(context.Background(), inst, ch); err != nil { + t.Errorf("Error calling AuroraStatLogicalWalCacheCollector.Update: %s", err) + } + }() + + expected := []struct { + desc *prometheus.Desc + result MetricResult + }{ + {auroraStatLogicalWalCacheHits, MetricResult{labels: labelMap{"slot_name": "test_slot1"}, value: 24, metricType: dto.MetricType_COUNTER}}, + {auroraStatLogicalWalCacheMisses, MetricResult{labels: labelMap{"slot_name": "test_slot1"}, value: 0, metricType: dto.MetricType_COUNTER}}, + {auroraStatLogicalWalCacheBlocksRead, MetricResult{labels: labelMap{"slot_name": "test_slot1"}, value: 24, metricType: dto.MetricType_COUNTER}}, + {auroraStatLogicalWalCacheHits, MetricResult{labels: labelMap{"slot_name": "test_slot2"}, value: 1, metricType: dto.MetricType_COUNTER}}, + {auroraStatLogicalWalCacheMisses, MetricResult{labels: labelMap{"slot_name": "test_slot2"}, value: 2, metricType: dto.MetricType_COUNTER}}, + {auroraStatLogicalWalCacheBlocksRead, MetricResult{labels: labelMap{"slot_name": "test_slot2"}, value: 3, metricType: dto.MetricType_COUNTER}}, + } + convey.Convey("Metrics comparison", t, func() { + for _, expect := range expected { + m := <-ch + convey.So(m.Desc(), convey.ShouldEqual, expect.desc) + convey.So(readMetric(m), convey.ShouldResemble, expect.result) + } + _, more := <-ch + convey.So(more, convey.ShouldBeFalse) + }) + if err := mock.ExpectationsWereMet(); err != nil { + t.Errorf("there were unfulfilled exceptions: %s", err) + } +} + +func TestAuroraStatLogicalWalCacheCollectorNotAurora(t *testing.T) { + db, mock, err := sqlmock.New() + if err != nil { + t.Fatalf("Error opening a stub db connection: %s", err) + } + defer db.Close() + + // No query is expected: the collector must not touch the database when + // the server is not Aurora. + inst := &instance{db: db, isAurora: false} + + ch := make(chan prometheus.Metric, 1) + c := AuroraStatLogicalWalCacheCollector{} + if err := c.Update(context.Background(), inst, ch); err != ErrNoData { + t.Errorf("Expected ErrNoData on non-Aurora server, got: %v", err) + } + if len(ch) != 0 { + t.Errorf("Expected no metrics on non-Aurora server, got %d", len(ch)) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Errorf("there were unfulfilled exceptions: %s", err) + } +} + +func TestAuroraStatLogicalWalCacheCollectorFunctionMissing(t *testing.T) { + db, mock, err := sqlmock.New() + if err != nil { + t.Fatalf("Error opening a stub db connection: %s", err) + } + defer db.Close() + + // Older Aurora versions don't have the function: only the existence + // check runs. + inst := &instance{db: db, isAurora: true} + expectFunctionExists(mock, "aurora_stat_logical_wal_cache", false) + + ch := make(chan prometheus.Metric, 1) + c := AuroraStatLogicalWalCacheCollector{} + if err := c.Update(context.Background(), inst, ch); err != ErrNoData { + t.Errorf("Expected ErrNoData when the function is missing, got: %v", err) + } + if len(ch) != 0 { + t.Errorf("Expected no metrics when the function is missing, got %d", len(ch)) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Errorf("there were unfulfilled exceptions: %s", err) + } +} + +func TestAuroraStatLogicalWalCacheCollectorNoRows(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, isAurora: true} + + expectFunctionExists(mock, "aurora_stat_logical_wal_cache", true) + mock.ExpectQuery(sanitizeQuery(auroraStatLogicalWalCacheQuery)).WillReturnRows(sqlmock.NewRows(auroraStatLogicalWalCacheColumns)) + + ch := make(chan prometheus.Metric, 1) + c := AuroraStatLogicalWalCacheCollector{} + if err := c.Update(context.Background(), inst, ch); err != ErrNoData { + t.Errorf("Expected ErrNoData, got: %v", err) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Errorf("there were unfulfilled exceptions: %s", err) + } +} diff --git a/collector/function_exists.go b/collector/function_exists.go new file mode 100644 index 00000000..615eb0e9 --- /dev/null +++ b/collector/function_exists.go @@ -0,0 +1,33 @@ +// Copyright The Prometheus Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package collector + +import ( + "context" + "database/sql" +) + +// functionExistsQuery uses to_regproc, which returns NULL instead of +// raising an error when the function doesn't exist. +const functionExistsQuery = `SELECT to_regproc($1) IS NOT NULL` + +// functionExists reports whether a function exists on the server. Some +// functions only exist in newer versions or on some types of instances, e.g. +// Amazon Aurora PostgreSQL; their collectors check first, so other servers +// report no data instead of an error on every scrape. +func functionExists(ctx context.Context, db *sql.DB, name string) (bool, error) { + var exists bool + err := db.QueryRowContext(ctx, functionExistsQuery, name).Scan(&exists) + return exists, err +} diff --git a/collector/function_exists_test.go b/collector/function_exists_test.go new file mode 100644 index 00000000..ad2c18ad --- /dev/null +++ b/collector/function_exists_test.go @@ -0,0 +1,50 @@ +// Copyright The Prometheus Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package collector + +import ( + "context" + "testing" + + "github.com/DATA-DOG/go-sqlmock" +) + +func expectFunctionExists(mock sqlmock.Sqlmock, name string, exists bool) { + mock.ExpectQuery(sanitizeQuery(functionExistsQuery)). + WithArgs(name). + WillReturnRows(sqlmock.NewRows([]string{"exists"}).AddRow(exists)) +} + +func TestFunctionExists(t *testing.T) { + for _, exists := range []bool{true, false} { + db, mock, err := sqlmock.New() + if err != nil { + t.Fatalf("Error opening a stub db connection: %s", err) + } + + expectFunctionExists(mock, "aurora_stat_logical_wal_cache", exists) + + got, err := functionExists(context.Background(), db, "aurora_stat_logical_wal_cache") + if err != nil { + t.Errorf("Error calling functionExists: %s", err) + } + if got != exists { + t.Errorf("Expected %v, got %v", exists, got) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Errorf("there were unfulfilled exceptions: %s", err) + } + db.Close() + } +} diff --git a/config/config.go b/config/config.go index b9ddb264..8420e5e4 100644 --- a/config/config.go +++ b/config/config.go @@ -73,8 +73,9 @@ const ( CollectorXlogLocation = "xlog_location" // Amazon Aurora PostgreSQL collectors. - CollectorAuroraGlobalDBStatus = "aurora_global_db_status" - CollectorAuroraReplicaStatus = "aurora_replica_status" + CollectorAuroraGlobalDBStatus = "aurora_global_db_status" + CollectorAuroraReplicaStatus = "aurora_replica_status" + CollectorAuroraStatLogicalWalCache = "aurora_stat_logical_wal_cache" ) type Config struct { @@ -233,8 +234,9 @@ func DefaultCollectorConfig() map[string]bool { CollectorXlogLocation: false, // Amazon Aurora PostgreSQL collectors. - CollectorAuroraGlobalDBStatus: false, - CollectorAuroraReplicaStatus: false, + CollectorAuroraGlobalDBStatus: false, + CollectorAuroraReplicaStatus: false, + CollectorAuroraStatLogicalWalCache: false, } } diff --git a/docs/configuration.md b/docs/configuration.md index 0fd2d353..82483f1f 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -58,6 +58,7 @@ The per-table and per-index collectors scale with your schema — one set of ser | `xlog_location` | disabled | Current WAL write position (or replay position on a replica) as a byte offset. Superseded by `stat_replication`; retained for old setups and pre-10 servers. | | `aurora_replica_status` | disabled | **Amazon Aurora PostgreSQL only.** For each instance in the cluster: replica lag behind the writer, replay latency, and pending page reads. On non-Aurora servers it reports nothing. From `aurora_replica_status()`. | | `aurora_global_db_status` | disabled | **Amazon Aurora PostgreSQL only.** For each secondary cluster of an Aurora global database, by AWS Region: storage (durability) lag and recovery point objective (RPO) lag behind the primary cluster, in seconds. On non-Aurora servers it reports nothing. From `aurora_global_db_status()`. | +| `aurora_stat_logical_wal_cache` | disabled | **Amazon Aurora PostgreSQL only.** Per logical replication slot: WAL cache hits, misses, and read requests. Requires Aurora PostgreSQL 11.17, 12.12, 13.8, 14.7, 15.2 or later; on older versions and on non-Aurora servers it reports nothing. From `aurora_stat_logical_wal_cache()`. | ### Checkpoints, buffers, and I/O