diff --git a/CHANGELOG.md b/CHANGELOG.md index 8cc3b9c47..ddae2873e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,7 +1,7 @@ ## main / (unreleased) * [CHANGE] ... -* [FEATURE] ... +* [FEATURE] Add Amazon Aurora PostgreSQL support: auto-detection via `aurora_version()` and 9 new `aurora_*` collectors, all disabled by default. * [ENHANCEMENT] ... * [BUGFIX] ... diff --git a/README.md b/README.md index dba089a9f..35f23dfe1 100644 --- a/README.md +++ b/README.md @@ -196,6 +196,39 @@ This will build the docker image as `prometheuscommunity/postgres_exporter:${bra * `[no-]collector.xlog_location` Enable the `xlog_location` collector (default: disabled). +#### Amazon Aurora PostgreSQL collectors + +These collectors expose metrics that only exist on Amazon Aurora PostgreSQL. +They are all disabled by default and detect Aurora automatically — when +enabled on a non-Aurora server they silently emit no data (no log noise). + +* `[no-]collector.aurora_replica_status` + Replica lag, replay latency, pending read IOs from `aurora_replica_status()`. (default: disabled) + +* `[no-]collector.aurora_global_db_status` + Per-region durability lag and RPO lag from `aurora_global_db_status()`. (default: disabled) + +* `[no-]collector.aurora_global_db_instance_status` + Per-instance visibility lag from `aurora_global_db_instance_status()`. (default: disabled) + +* `[no-]collector.aurora_stat_database` + Aurora-specific per-database I/O counters (storage / optimized reads cache / local) from `aurora_stat_database()`. (default: disabled) + +* `[no-]collector.aurora_stat_bgwriter` + Aurora Optimized Reads cache writes from `aurora_stat_bgwriter()`. (default: disabled) + +* `[no-]collector.aurora_stat_commit_latency` + Cumulative per-database commit latency from `aurora_stat_get_db_commit_latency()`. (default: disabled) + +* `[no-]collector.aurora_stat_dml_activity` + Per-database SELECT/INSERT/UPDATE/DELETE counts and latency from `aurora_stat_dml_activity()`. (default: disabled) + +* `[no-]collector.aurora_stat_optimized_reads_cache` + Aurora Optimized Reads cache total/used size from `aurora_stat_optimized_reads_cache()`. (default: disabled) + +* `[no-]collector.aurora_stat_logical_wal_cache` + Per-replication-slot WAL cache hits/misses from `aurora_stat_logical_wal_cache()`. (default: disabled) + * `config.file` Set the config file path. Default is `postgres_exporter.yml` diff --git a/collector/aurora_global_db_instance_status.go b/collector/aurora_global_db_instance_status.go new file mode 100644 index 000000000..709152c16 --- /dev/null +++ b/collector/aurora_global_db_instance_status.go @@ -0,0 +1,84 @@ +// Copyright 2025 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" +) + +// aurora_global_db_instance_status() exposes per-instance lag across regions +// in an Aurora Global Database. Disabled by default; only useful on Aurora. +const auroraGlobalDBInstanceSubsystem = "aurora_global_db_instance" + +func init() { + registerCollector("aurora_global_db_instance_status", defaultDisabled, NewAuroraGlobalDBInstanceStatusCollector) +} + +type AuroraGlobalDBInstanceStatusCollector struct{} + +func NewAuroraGlobalDBInstanceStatusCollector(collectorConfig) (Collector, error) { + return &AuroraGlobalDBInstanceStatusCollector{}, nil +} + +var ( + auroraGlobalDBInstanceVisibilityLagDesc = prometheus.NewDesc( + prometheus.BuildFQName(namespace, auroraGlobalDBInstanceSubsystem, "visibility_lag_milliseconds"), + "How far this DB instance is lagging behind the writer DB instance in milliseconds. NULL for the writer.", + []string{"server_id", "aws_region"}, nil, + ) + + auroraGlobalDBInstanceStatusQuery = `SELECT + server_id, + aws_region, + visibility_lag_in_msec + FROM aurora_global_db_instance_status()` +) + +func (c AuroraGlobalDBInstanceStatusCollector) Update(ctx context.Context, instance *instance, ch chan<- prometheus.Metric) error { + if !instance.isAurora { + return ErrNoData + } + rows, err := instance.getDB().QueryContext(ctx, auroraGlobalDBInstanceStatusQuery) + if err != nil { + return err + } + defer rows.Close() + + var found bool + for rows.Next() { + found = true + + var serverID, awsRegion string + var visibilityLag sql.NullFloat64 + + if err := rows.Scan(&serverID, &awsRegion, &visibilityLag); err != nil { + return err + } + + if visibilityLag.Valid { + ch <- prometheus.MustNewConstMetric(auroraGlobalDBInstanceVisibilityLagDesc, prometheus.GaugeValue, visibilityLag.Float64, serverID, awsRegion) + } + } + + if err := rows.Err(); err != nil { + return err + } + if !found { + return ErrNoData + } + return nil +} diff --git a/collector/aurora_global_db_instance_status_test.go b/collector/aurora_global_db_instance_status_test.go new file mode 100644 index 000000000..6f153257a --- /dev/null +++ b/collector/aurora_global_db_instance_status_test.go @@ -0,0 +1,81 @@ +// Copyright 2025 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" + "github.com/smartystreets/goconvey/convey" +) + +func TestAuroraGlobalDBInstanceStatusCollector(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} + + columns := []string{"server_id", "aws_region", "visibility_lag_in_msec"} + rows := sqlmock.NewRows(columns). + AddRow("writer", "eu-west-1", nil). // writer: NULL lag, skipped + AddRow("reader-1", "eu-west-1", 6.0). + AddRow("reader-2", "eu-central-1", 996.0) + mock.ExpectQuery(sanitizeQuery(auroraGlobalDBInstanceStatusQuery)).WillReturnRows(rows) + + ch := make(chan prometheus.Metric) + go func() { + defer close(ch) + c := AuroraGlobalDBInstanceStatusCollector{} + if err := c.Update(context.Background(), inst, ch); err != nil { + t.Errorf("Error calling Update: %s", err) + } + }() + + got := 0 + for range ch { + got++ + } + convey.Convey("Metrics count", t, func() { + convey.So(got, convey.ShouldEqual, 2) // writer skipped (NULL), 2 readers emitted + if err := mock.ExpectationsWereMet(); err != nil { + t.Errorf("Unfulfilled expectations: %s", err) + } + }) +} + +func TestAuroraGlobalDBInstanceStatusCollectorNotAurora(t *testing.T) { + db, _, err := sqlmock.New() + if err != nil { + t.Fatalf("Error opening a stub db connection: %s", err) + } + defer db.Close() + + inst := &instance{db: db, isAurora: false} + + ch := make(chan prometheus.Metric) + go func() { + defer close(ch) + c := AuroraGlobalDBInstanceStatusCollector{} + if err := c.Update(context.Background(), inst, ch); err != ErrNoData { + t.Errorf("Expected ErrNoData, got: %v", err) + } + }() + for range ch { + } +} diff --git a/collector/aurora_global_db_status.go b/collector/aurora_global_db_status.go new file mode 100644 index 000000000..822e62718 --- /dev/null +++ b/collector/aurora_global_db_status.go @@ -0,0 +1,93 @@ +// Copyright 2025 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" +) + +// auroraGlobalDBSubsystem exposes metrics from Aurora PostgreSQL's +// aurora_global_db_status() function. Disabled by default; only useful for +// Aurora Global Database deployments. +const auroraGlobalDBSubsystem = "aurora_global_db" + +func init() { + registerCollector("aurora_global_db_status", defaultDisabled, NewAuroraGlobalDBStatusCollector) +} + +type AuroraGlobalDBStatusCollector struct{} + +func NewAuroraGlobalDBStatusCollector(collectorConfig) (Collector, error) { + return &AuroraGlobalDBStatusCollector{}, nil +} + +var ( + auroraGlobalDurabilityLagDesc = prometheus.NewDesc( + prometheus.BuildFQName(namespace, auroraGlobalDBSubsystem, "durability_lag_milliseconds"), + "Storage lag vs primary cluster in milliseconds (-1 for the primary cluster).", + []string{"aws_region"}, nil, + ) + auroraGlobalRPOLagDesc = prometheus.NewDesc( + prometheus.BuildFQName(namespace, auroraGlobalDBSubsystem, "rpo_lag_milliseconds"), + "Recovery point objective lag in milliseconds (-1 for the primary cluster).", + []string{"aws_region"}, nil, + ) + + auroraGlobalDBStatusQuery = `SELECT + aws_region, + durability_lag_in_msec, + rpo_lag_in_msec + FROM aurora_global_db_status()` +) + +func (c AuroraGlobalDBStatusCollector) Update(ctx context.Context, instance *instance, ch chan<- prometheus.Metric) error { + if !instance.isAurora { + return ErrNoData + } + rows, err := instance.getDB().QueryContext(ctx, auroraGlobalDBStatusQuery) + if err != nil { + return err + } + defer rows.Close() + + var found bool + for rows.Next() { + found = true + + var awsRegion string + var durabilityLag, rpoLag sql.NullFloat64 + + if err := rows.Scan(&awsRegion, &durabilityLag, &rpoLag); err != nil { + return err + } + + if durabilityLag.Valid { + ch <- prometheus.MustNewConstMetric(auroraGlobalDurabilityLagDesc, prometheus.GaugeValue, durabilityLag.Float64, awsRegion) + } + if rpoLag.Valid { + ch <- prometheus.MustNewConstMetric(auroraGlobalRPOLagDesc, prometheus.GaugeValue, rpoLag.Float64, awsRegion) + } + } + + if err := rows.Err(); err != nil { + return err + } + if !found { + return ErrNoData + } + return nil +} diff --git a/collector/aurora_global_db_status_test.go b/collector/aurora_global_db_status_test.go new file mode 100644 index 000000000..f44a57cbd --- /dev/null +++ b/collector/aurora_global_db_status_test.go @@ -0,0 +1,118 @@ +// Copyright 2025 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" + "github.com/smartystreets/goconvey/convey" +) + +func TestAuroraGlobalDBStatusCollector(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} + + columns := []string{ + "aws_region", + "durability_lag_in_msec", + "rpo_lag_in_msec", + } + rows := sqlmock.NewRows(columns). + AddRow("us-east-1", -1.0, -1.0). + AddRow("eu-west-1", 50.0, 75.0) + + mock.ExpectQuery(sanitizeQuery(auroraGlobalDBStatusQuery)).WillReturnRows(rows) + + ch := make(chan prometheus.Metric) + go func() { + defer close(ch) + c := AuroraGlobalDBStatusCollector{} + if err := c.Update(context.Background(), inst, ch); err != nil { + t.Errorf("Error calling Update: %s", err) + } + }() + + expected := 4 // 2 metrics × 2 rows + got := 0 + for range ch { + got++ + } + + convey.Convey("Metrics count", t, func() { + convey.So(got, convey.ShouldEqual, expected) + if err := mock.ExpectationsWereMet(); err != nil { + t.Errorf("Unfulfilled expectations: %s", err) + } + }) +} + +func TestAuroraGlobalDBStatusCollectorNotAurora(t *testing.T) { + db, _, err := sqlmock.New() + if err != nil { + t.Fatalf("Error opening a stub db connection: %s", err) + } + defer db.Close() + + inst := &instance{db: db, isAurora: false} + + ch := make(chan prometheus.Metric) + go func() { + defer close(ch) + c := AuroraGlobalDBStatusCollector{} + if err := c.Update(context.Background(), inst, ch); err != ErrNoData { + t.Errorf("Expected ErrNoData on non-Aurora, got: %v", err) + } + }() + for range ch { + } +} + +func TestAuroraGlobalDBStatusCollectorNoData(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} + + columns := []string{ + "aws_region", + "durability_lag_in_msec", + "rpo_lag_in_msec", + } + rows := sqlmock.NewRows(columns) + mock.ExpectQuery(sanitizeQuery(auroraGlobalDBStatusQuery)).WillReturnRows(rows) + + ch := make(chan prometheus.Metric) + go func() { + defer close(ch) + c := AuroraGlobalDBStatusCollector{} + err := c.Update(context.Background(), inst, ch) + if err != ErrNoData { + t.Errorf("Expected ErrNoData, got: %v", err) + } + }() + + for range ch { + } +} diff --git a/collector/aurora_replica_status.go b/collector/aurora_replica_status.go new file mode 100644 index 000000000..02bc31850 --- /dev/null +++ b/collector/aurora_replica_status.go @@ -0,0 +1,103 @@ +// Copyright 2025 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" +) + +// auroraReplicaStatusCollector exposes metrics from Aurora PostgreSQL's +// aurora_replica_status() function. Disabled by default because the function +// only exists on Aurora. +const auroraReplicaSubsystem = "aurora_replica" + +func init() { + registerCollector("aurora_replica_status", defaultDisabled, NewAuroraReplicaStatusCollector) +} + +type AuroraReplicaStatusCollector struct{} + +func NewAuroraReplicaStatusCollector(collectorConfig) (Collector, error) { + return &AuroraReplicaStatusCollector{}, nil +} + +var ( + auroraReplicaLagDesc = prometheus.NewDesc( + prometheus.BuildFQName(namespace, auroraReplicaSubsystem, "lag_milliseconds"), + "Replica lag behind writer in milliseconds (aurora_replica_status.replica_lag_in_msec).", + []string{"server_id"}, nil, + ) + auroraReplayLatencyDesc = prometheus.NewDesc( + prometheus.BuildFQName(namespace, auroraReplicaSubsystem, "replay_latency_microseconds"), + "Expected log replay latency in microseconds (aurora_replica_status.cur_replay_latency_in_usec).", + []string{"server_id"}, nil, + ) + auroraPendingReadIOsDesc = prometheus.NewDesc( + prometheus.BuildFQName(namespace, auroraReplicaSubsystem, "pending_read_ios"), + "Outstanding page reads pending on instance.", + []string{"server_id"}, nil, + ) + + auroraReplicaStatusQuery = `SELECT + server_id, + replica_lag_in_msec, + cur_replay_latency_in_usec, + pending_read_ios + FROM aurora_replica_status()` +) + +func (c AuroraReplicaStatusCollector) Update(ctx context.Context, instance *instance, ch chan<- prometheus.Metric) error { + if !instance.isAurora { + return ErrNoData + } + rows, err := instance.getDB().QueryContext(ctx, auroraReplicaStatusQuery) + if err != nil { + return err + } + defer rows.Close() + + var found bool + for rows.Next() { + found = true + + var serverID string + var replicaLag, replayLatency sql.NullFloat64 + var pendingReadIOs sql.NullInt64 + + if err := rows.Scan(&serverID, &replicaLag, &replayLatency, &pendingReadIOs); err != nil { + return err + } + + if replicaLag.Valid { + ch <- prometheus.MustNewConstMetric(auroraReplicaLagDesc, prometheus.GaugeValue, replicaLag.Float64, serverID) + } + if replayLatency.Valid { + ch <- prometheus.MustNewConstMetric(auroraReplayLatencyDesc, prometheus.GaugeValue, replayLatency.Float64, serverID) + } + if pendingReadIOs.Valid { + ch <- prometheus.MustNewConstMetric(auroraPendingReadIOsDesc, prometheus.GaugeValue, float64(pendingReadIOs.Int64), serverID) + } + } + + if err := rows.Err(); err != nil { + return err + } + if !found { + return ErrNoData + } + return nil +} diff --git a/collector/aurora_replica_status_test.go b/collector/aurora_replica_status_test.go new file mode 100644 index 000000000..2bac6b09a --- /dev/null +++ b/collector/aurora_replica_status_test.go @@ -0,0 +1,122 @@ +// Copyright 2025 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" + "github.com/smartystreets/goconvey/convey" +) + +func TestAuroraReplicaStatusCollector(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} + + columns := []string{ + "server_id", + "replica_lag_in_msec", + "cur_replay_latency_in_usec", + "pending_read_ios", + } + rows := sqlmock.NewRows(columns). + AddRow("writer-instance", nil, nil, 0). + AddRow("reader-instance-1", 15.5, 200.0, 3) + + mock.ExpectQuery(sanitizeQuery(auroraReplicaStatusQuery)).WillReturnRows(rows) + + ch := make(chan prometheus.Metric) + go func() { + defer close(ch) + c := AuroraReplicaStatusCollector{} + if err := c.Update(context.Background(), inst, ch); err != nil { + t.Errorf("Error calling Update: %s", err) + } + }() + + // writer: 1 metric (pending_read_ios), reader: 3 metrics → 4 total + expected := 4 + got := 0 + for range ch { + got++ + } + + convey.Convey("Metrics count", t, func() { + convey.So(got, convey.ShouldEqual, expected) + if err := mock.ExpectationsWereMet(); err != nil { + t.Errorf("Unfulfilled expectations: %s", err) + } + }) +} + +func TestAuroraReplicaStatusCollectorNotAurora(t *testing.T) { + db, _, err := sqlmock.New() + if err != nil { + t.Fatalf("Error opening a stub db connection: %s", err) + } + defer db.Close() + + // isAurora=false → collector must skip immediately and not even attempt the query. + inst := &instance{db: db, isAurora: false} + + ch := make(chan prometheus.Metric) + go func() { + defer close(ch) + c := AuroraReplicaStatusCollector{} + if err := c.Update(context.Background(), inst, ch); err != ErrNoData { + t.Errorf("Expected ErrNoData on non-Aurora, got: %v", err) + } + }() + for range ch { + } +} + +func TestAuroraReplicaStatusCollectorNoData(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} + + columns := []string{ + "server_id", + "replica_lag_in_msec", + "cur_replay_latency_in_usec", + "pending_read_ios", + } + rows := sqlmock.NewRows(columns) + mock.ExpectQuery(sanitizeQuery(auroraReplicaStatusQuery)).WillReturnRows(rows) + + ch := make(chan prometheus.Metric) + go func() { + defer close(ch) + c := AuroraReplicaStatusCollector{} + err := c.Update(context.Background(), inst, ch) + if err != ErrNoData { + t.Errorf("Expected ErrNoData, got: %v", err) + } + }() + + for range ch { + } +} diff --git a/collector/aurora_stat_bgwriter.go b/collector/aurora_stat_bgwriter.go new file mode 100644 index 000000000..e4e2ef570 --- /dev/null +++ b/collector/aurora_stat_bgwriter.go @@ -0,0 +1,74 @@ +// Copyright 2025 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" +) + +// aurora_stat_bgwriter() exposes Aurora's Optimized Reads cache writes. +// Only the Aurora-specific columns are exported; standard pg_stat_bgwriter +// metrics are covered by the pg_stat_bgwriter collector. Available in +// Aurora PostgreSQL 14.9+ / 15.4+. +const auroraStatBgwriterSubsystem = "aurora_stat_bgwriter" + +func init() { + registerCollector("aurora_stat_bgwriter", defaultDisabled, NewAuroraStatBgwriterCollector) +} + +type AuroraStatBgwriterCollector struct{} + +func NewAuroraStatBgwriterCollector(collectorConfig) (Collector, error) { + return &AuroraStatBgwriterCollector{}, nil +} + +var ( + auroraBgwriterOrcacheBlksWrittenDesc = prometheus.NewDesc( + prometheus.BuildFQName(namespace, auroraStatBgwriterSubsystem, "orcache_blocks_written_total"), + "Total number of optimized reads cache data blocks written.", + nil, nil, + ) + auroraBgwriterOrcacheBlkWriteTimeDesc = prometheus.NewDesc( + prometheus.BuildFQName(namespace, auroraStatBgwriterSubsystem, "orcache_block_write_time_seconds_total"), + "Total time spent writing optimized reads cache data file blocks in seconds (track_io_timing must be enabled).", + nil, nil, + ) + + auroraStatBgwriterQuery = `SELECT orcache_blks_written, orcache_blk_write_time FROM aurora_stat_bgwriter()` +) + +func (c AuroraStatBgwriterCollector) Update(ctx context.Context, instance *instance, ch chan<- prometheus.Metric) error { + if !instance.isAurora { + return ErrNoData + } + row := instance.getDB().QueryRowContext(ctx, auroraStatBgwriterQuery) + + var blksWritten sql.NullInt64 + var blkWriteTimeMs sql.NullFloat64 + if err := row.Scan(&blksWritten, &blkWriteTimeMs); err != nil { + return err + } + + if blksWritten.Valid { + ch <- prometheus.MustNewConstMetric(auroraBgwriterOrcacheBlksWrittenDesc, prometheus.CounterValue, float64(blksWritten.Int64)) + } + if blkWriteTimeMs.Valid { + // AWS returns milliseconds; Prometheus convention is seconds. + ch <- prometheus.MustNewConstMetric(auroraBgwriterOrcacheBlkWriteTimeDesc, prometheus.CounterValue, blkWriteTimeMs.Float64/1000) + } + return nil +} diff --git a/collector/aurora_stat_bgwriter_test.go b/collector/aurora_stat_bgwriter_test.go new file mode 100644 index 000000000..365b9f24f --- /dev/null +++ b/collector/aurora_stat_bgwriter_test.go @@ -0,0 +1,78 @@ +// Copyright 2025 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" + "github.com/smartystreets/goconvey/convey" +) + +func TestAuroraStatBgwriterCollector(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} + + mock.ExpectQuery(sanitizeQuery(auroraStatBgwriterQuery)). + WillReturnRows(sqlmock.NewRows([]string{"orcache_blks_written", "orcache_blk_write_time"}). + AddRow(246522, 339276.404)) + + ch := make(chan prometheus.Metric) + go func() { + defer close(ch) + c := AuroraStatBgwriterCollector{} + if err := c.Update(context.Background(), inst, ch); err != nil { + t.Errorf("Error calling Update: %s", err) + } + }() + + got := 0 + for range ch { + got++ + } + convey.Convey("Two counters emitted", t, func() { + convey.So(got, convey.ShouldEqual, 2) + if err := mock.ExpectationsWereMet(); err != nil { + t.Errorf("Unfulfilled expectations: %s", err) + } + }) +} + +func TestAuroraStatBgwriterCollectorNotAurora(t *testing.T) { + db, _, err := sqlmock.New() + if err != nil { + t.Fatalf("Error opening a stub db connection: %s", err) + } + defer db.Close() + + inst := &instance{db: db, isAurora: false} + + ch := make(chan prometheus.Metric) + go func() { + defer close(ch) + c := AuroraStatBgwriterCollector{} + if err := c.Update(context.Background(), inst, ch); err != ErrNoData { + t.Errorf("Expected ErrNoData, got: %v", err) + } + }() + for range ch { + } +} diff --git a/collector/aurora_stat_commit_latency.go b/collector/aurora_stat_commit_latency.go new file mode 100644 index 000000000..be75cc6da --- /dev/null +++ b/collector/aurora_stat_commit_latency.go @@ -0,0 +1,97 @@ +// Copyright 2025 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" +) + +// aurora_stat_get_db_commit_latency(oid) returns the cumulative commit +// latency in microseconds for an Aurora PostgreSQL database. We join with +// pg_database in a single query so each scrape executes one round-trip. +const auroraStatCommitLatencySubsystem = "aurora_stat_commit_latency" + +func init() { + registerCollector("aurora_stat_commit_latency", defaultDisabled, NewAuroraStatCommitLatencyCollector) +} + +type AuroraStatCommitLatencyCollector struct { + excludeDatabases []string +} + +func NewAuroraStatCommitLatencyCollector(config collectorConfig) (Collector, error) { + return &AuroraStatCommitLatencyCollector{excludeDatabases: config.excludeDatabases}, nil +} + +var ( + auroraStatCommitLatencyDesc = prometheus.NewDesc( + prometheus.BuildFQName(namespace, auroraStatCommitLatencySubsystem, "microseconds_total"), + "Cumulative commit latency in microseconds per database (aurora_stat_get_db_commit_latency).", + []string{"datid", "datname"}, nil, + ) + + auroraStatCommitLatencyQuery = `SELECT + oid::text AS datid, + datname, + aurora_stat_get_db_commit_latency(oid) AS latency + FROM pg_database + WHERE datallowconn` +) + +func (c AuroraStatCommitLatencyCollector) Update(ctx context.Context, instance *instance, ch chan<- prometheus.Metric) error { + if !instance.isAurora { + return ErrNoData + } + rows, err := instance.getDB().QueryContext(ctx, auroraStatCommitLatencyQuery) + if err != nil { + return err + } + defer rows.Close() + + excluded := make(map[string]struct{}, len(c.excludeDatabases)) + for _, d := range c.excludeDatabases { + excluded[d] = struct{}{} + } + + var found bool + for rows.Next() { + found = true + + var datid, datname sql.NullString + var latency sql.NullInt64 + if err := rows.Scan(&datid, &datname, &latency); err != nil { + return err + } + if !datname.Valid { + continue + } + if _, skip := excluded[datname.String]; skip { + continue + } + if latency.Valid { + ch <- prometheus.MustNewConstMetric(auroraStatCommitLatencyDesc, prometheus.CounterValue, float64(latency.Int64), datid.String, datname.String) + } + } + + if err := rows.Err(); err != nil { + return err + } + if !found { + return ErrNoData + } + return nil +} diff --git a/collector/aurora_stat_commit_latency_test.go b/collector/aurora_stat_commit_latency_test.go new file mode 100644 index 000000000..d611d9bf7 --- /dev/null +++ b/collector/aurora_stat_commit_latency_test.go @@ -0,0 +1,80 @@ +// Copyright 2025 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" + "github.com/smartystreets/goconvey/convey" +) + +func TestAuroraStatCommitLatencyCollector(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} + + mock.ExpectQuery(sanitizeQuery(auroraStatCommitLatencyQuery)). + WillReturnRows(sqlmock.NewRows([]string{"datid", "datname", "latency"}). + AddRow("16401", "mydb", 229556). + AddRow("69768", "postgres", 22011). + AddRow("16384", "rdsadmin", 654387789)) + + ch := make(chan prometheus.Metric) + go func() { + defer close(ch) + c := AuroraStatCommitLatencyCollector{excludeDatabases: []string{"rdsadmin"}} + if err := c.Update(context.Background(), inst, ch); err != nil { + t.Errorf("Error calling Update: %s", err) + } + }() + + got := 0 + for range ch { + got++ + } + convey.Convey("rdsadmin excluded → 2 metrics", t, func() { + convey.So(got, convey.ShouldEqual, 2) + if err := mock.ExpectationsWereMet(); err != nil { + t.Errorf("Unfulfilled expectations: %s", err) + } + }) +} + +func TestAuroraStatCommitLatencyCollectorNotAurora(t *testing.T) { + db, _, err := sqlmock.New() + if err != nil { + t.Fatalf("Error opening a stub db connection: %s", err) + } + defer db.Close() + + inst := &instance{db: db, isAurora: false} + + ch := make(chan prometheus.Metric) + go func() { + defer close(ch) + c := AuroraStatCommitLatencyCollector{} + if err := c.Update(context.Background(), inst, ch); err != ErrNoData { + t.Errorf("Expected ErrNoData, got: %v", err) + } + }() + for range ch { + } +} diff --git a/collector/aurora_stat_database.go b/collector/aurora_stat_database.go new file mode 100644 index 000000000..3e0075cd3 --- /dev/null +++ b/collector/aurora_stat_database.go @@ -0,0 +1,160 @@ +// Copyright 2025 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" +) + +// aurora_stat_database() returns all pg_stat_database columns plus +// Aurora-specific ones for storage/local/orcache block reads. Only the +// Aurora-specific columns are exported here — standard pg_stat_database +// metrics are provided by the stat_database collector. Available in +// Aurora PostgreSQL 14.9+ / 15.4+. +const auroraStatDatabaseSubsystem = "aurora_stat_database" + +func init() { + registerCollector("aurora_stat_database", defaultDisabled, NewAuroraStatDatabaseCollector) +} + +type AuroraStatDatabaseCollector struct { + excludeDatabases []string +} + +func NewAuroraStatDatabaseCollector(config collectorConfig) (Collector, error) { + return &AuroraStatDatabaseCollector{excludeDatabases: config.excludeDatabases}, nil +} + +var ( + auroraStatDBStorageBlksReadDesc = prometheus.NewDesc( + prometheus.BuildFQName(namespace, auroraStatDatabaseSubsystem, "storage_blocks_read_total"), + "Total number of shared blocks read from Aurora storage in this database.", + []string{"datid", "datname"}, nil, + ) + auroraStatDBOrcacheBlksHitDesc = prometheus.NewDesc( + prometheus.BuildFQName(namespace, auroraStatDatabaseSubsystem, "orcache_blocks_hit_total"), + "Total number of optimized reads cache hits in this database.", + []string{"datid", "datname"}, nil, + ) + auroraStatDBLocalBlksReadDesc = prometheus.NewDesc( + prometheus.BuildFQName(namespace, auroraStatDatabaseSubsystem, "local_blocks_read_total"), + "Total number of local blocks read in this database.", + []string{"datid", "datname"}, nil, + ) + auroraStatDBStorageBlkReadTimeDesc = prometheus.NewDesc( + prometheus.BuildFQName(namespace, auroraStatDatabaseSubsystem, "storage_block_read_time_seconds_total"), + "Total time spent reading data file blocks from Aurora storage in seconds.", + []string{"datid", "datname"}, nil, + ) + auroraStatDBOrcacheBlkReadTimeDesc = prometheus.NewDesc( + prometheus.BuildFQName(namespace, auroraStatDatabaseSubsystem, "orcache_block_read_time_seconds_total"), + "Total time spent reading data file blocks from optimized reads cache in seconds.", + []string{"datid", "datname"}, nil, + ) + auroraStatDBLocalBlkReadTimeDesc = prometheus.NewDesc( + prometheus.BuildFQName(namespace, auroraStatDatabaseSubsystem, "local_block_read_time_seconds_total"), + "Total time spent reading local data file blocks in seconds.", + []string{"datid", "datname"}, nil, + ) + + auroraStatDatabaseQuery = `SELECT + datid, + datname, + storage_blks_read, + orcache_blks_hit, + local_blks_read, + storage_blk_read_time, + orcache_blk_read_time, + local_blk_read_time + FROM aurora_stat_database() + WHERE datname IS NOT NULL` +) + +func (c AuroraStatDatabaseCollector) Update(ctx context.Context, instance *instance, ch chan<- prometheus.Metric) error { + if !instance.isAurora { + return ErrNoData + } + rows, err := instance.getDB().QueryContext(ctx, auroraStatDatabaseQuery) + if err != nil { + return err + } + defer rows.Close() + + excluded := make(map[string]struct{}, len(c.excludeDatabases)) + for _, d := range c.excludeDatabases { + excluded[d] = struct{}{} + } + + var found bool + for rows.Next() { + found = true + + var datid sql.NullString + var datname sql.NullString + var storageBlksRead, orcacheBlksHit, localBlksRead sql.NullInt64 + var storageBlkReadTimeMs, orcacheBlkReadTimeMs, localBlkReadTimeMs sql.NullFloat64 + + if err := rows.Scan( + &datid, + &datname, + &storageBlksRead, + &orcacheBlksHit, + &localBlksRead, + &storageBlkReadTimeMs, + &orcacheBlkReadTimeMs, + &localBlkReadTimeMs, + ); err != nil { + return err + } + + if !datname.Valid { + continue + } + if _, skip := excluded[datname.String]; skip { + continue + } + + labels := []string{datid.String, datname.String} + + if storageBlksRead.Valid { + ch <- prometheus.MustNewConstMetric(auroraStatDBStorageBlksReadDesc, prometheus.CounterValue, float64(storageBlksRead.Int64), labels...) + } + if orcacheBlksHit.Valid { + ch <- prometheus.MustNewConstMetric(auroraStatDBOrcacheBlksHitDesc, prometheus.CounterValue, float64(orcacheBlksHit.Int64), labels...) + } + if localBlksRead.Valid { + ch <- prometheus.MustNewConstMetric(auroraStatDBLocalBlksReadDesc, prometheus.CounterValue, float64(localBlksRead.Int64), labels...) + } + if storageBlkReadTimeMs.Valid { + ch <- prometheus.MustNewConstMetric(auroraStatDBStorageBlkReadTimeDesc, prometheus.CounterValue, storageBlkReadTimeMs.Float64/1000, labels...) + } + if orcacheBlkReadTimeMs.Valid { + ch <- prometheus.MustNewConstMetric(auroraStatDBOrcacheBlkReadTimeDesc, prometheus.CounterValue, orcacheBlkReadTimeMs.Float64/1000, labels...) + } + if localBlkReadTimeMs.Valid { + ch <- prometheus.MustNewConstMetric(auroraStatDBLocalBlkReadTimeDesc, prometheus.CounterValue, localBlkReadTimeMs.Float64/1000, labels...) + } + } + + if err := rows.Err(); err != nil { + return err + } + if !found { + return ErrNoData + } + return nil +} diff --git a/collector/aurora_stat_database_test.go b/collector/aurora_stat_database_test.go new file mode 100644 index 000000000..7bc8dfe19 --- /dev/null +++ b/collector/aurora_stat_database_test.go @@ -0,0 +1,84 @@ +// Copyright 2025 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" + "github.com/smartystreets/goconvey/convey" +) + +func TestAuroraStatDatabaseCollector(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} + + columns := []string{ + "datid", "datname", + "storage_blks_read", "orcache_blks_hit", "local_blks_read", + "storage_blk_read_time", "orcache_blk_read_time", "local_blk_read_time", + } + rows := sqlmock.NewRows(columns). + AddRow("14717", "postgres", 623, 425, 0, 3254.914, 89.934, 0.0). + AddRow("16384", "rdsadmin", 100, 50, 0, 200.0, 30.0, 0.0) + mock.ExpectQuery(sanitizeQuery(auroraStatDatabaseQuery)).WillReturnRows(rows) + + ch := make(chan prometheus.Metric) + go func() { + defer close(ch) + c := AuroraStatDatabaseCollector{excludeDatabases: []string{"rdsadmin"}} + if err := c.Update(context.Background(), inst, ch); err != nil { + t.Errorf("Error calling Update: %s", err) + } + }() + + got := 0 + for range ch { + got++ + } + convey.Convey("6 metrics × 1 non-excluded database = 6", t, func() { + convey.So(got, convey.ShouldEqual, 6) + if err := mock.ExpectationsWereMet(); err != nil { + t.Errorf("Unfulfilled expectations: %s", err) + } + }) +} + +func TestAuroraStatDatabaseCollectorNotAurora(t *testing.T) { + db, _, err := sqlmock.New() + if err != nil { + t.Fatalf("Error opening a stub db connection: %s", err) + } + defer db.Close() + + inst := &instance{db: db, isAurora: false} + + ch := make(chan prometheus.Metric) + go func() { + defer close(ch) + c := AuroraStatDatabaseCollector{} + if err := c.Update(context.Background(), inst, ch); err != ErrNoData { + t.Errorf("Expected ErrNoData, got: %v", err) + } + }() + for range ch { + } +} diff --git a/collector/aurora_stat_dml_activity.go b/collector/aurora_stat_dml_activity.go new file mode 100644 index 000000000..c372b37a5 --- /dev/null +++ b/collector/aurora_stat_dml_activity.go @@ -0,0 +1,120 @@ +// Copyright 2025 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" +) + +// aurora_stat_dml_activity(oid) returns cumulative per-operation +// counts and latency (in microseconds) for SELECT/INSERT/UPDATE/DELETE. +// Available on Aurora PostgreSQL 11.6+. +const auroraStatDMLActivitySubsystem = "aurora_stat_dml_activity" + +func init() { + registerCollector("aurora_stat_dml_activity", defaultDisabled, NewAuroraStatDMLActivityCollector) +} + +type AuroraStatDMLActivityCollector struct { + excludeDatabases []string +} + +func NewAuroraStatDMLActivityCollector(config collectorConfig) (Collector, error) { + return &AuroraStatDMLActivityCollector{excludeDatabases: config.excludeDatabases}, nil +} + +var ( + auroraDMLCountDesc = prometheus.NewDesc( + prometheus.BuildFQName(namespace, auroraStatDMLActivitySubsystem, "operations_total"), + "Cumulative count of DML operations per database and operation type (aurora_stat_dml_activity).", + []string{"datid", "datname", "operation"}, nil, + ) + auroraDMLLatencyDesc = prometheus.NewDesc( + prometheus.BuildFQName(namespace, auroraStatDMLActivitySubsystem, "latency_microseconds_total"), + "Cumulative DML latency in microseconds per database and operation type (aurora_stat_dml_activity).", + []string{"datid", "datname", "operation"}, nil, + ) + + auroraStatDMLActivityQuery = `SELECT + pg_database.oid::text AS datid, + pg_database.datname, + (aurora_stat_dml_activity(pg_database.oid)).* + FROM pg_database + WHERE datallowconn` +) + +func (c AuroraStatDMLActivityCollector) Update(ctx context.Context, instance *instance, ch chan<- prometheus.Metric) error { + if !instance.isAurora { + return ErrNoData + } + rows, err := instance.getDB().QueryContext(ctx, auroraStatDMLActivityQuery) + if err != nil { + return err + } + defer rows.Close() + + excluded := make(map[string]struct{}, len(c.excludeDatabases)) + for _, d := range c.excludeDatabases { + excluded[d] = struct{}{} + } + + var found bool + for rows.Next() { + found = true + + var datid, datname sql.NullString + var selectCount, selectLat, insertCount, insertLat sql.NullInt64 + var updateCount, updateLat, deleteCount, deleteLat sql.NullInt64 + if err := rows.Scan( + &datid, &datname, + &selectCount, &selectLat, + &insertCount, &insertLat, + &updateCount, &updateLat, + &deleteCount, &deleteLat, + ); err != nil { + return err + } + if !datname.Valid { + continue + } + if _, skip := excluded[datname.String]; skip { + continue + } + + emit := func(op string, count, latency sql.NullInt64) { + labels := []string{datid.String, datname.String, op} + if count.Valid { + ch <- prometheus.MustNewConstMetric(auroraDMLCountDesc, prometheus.CounterValue, float64(count.Int64), labels...) + } + if latency.Valid { + ch <- prometheus.MustNewConstMetric(auroraDMLLatencyDesc, prometheus.CounterValue, float64(latency.Int64), labels...) + } + } + emit("select", selectCount, selectLat) + emit("insert", insertCount, insertLat) + emit("update", updateCount, updateLat) + emit("delete", deleteCount, deleteLat) + } + + if err := rows.Err(); err != nil { + return err + } + if !found { + return ErrNoData + } + return nil +} diff --git a/collector/aurora_stat_dml_activity_test.go b/collector/aurora_stat_dml_activity_test.go new file mode 100644 index 000000000..3fb41b5cb --- /dev/null +++ b/collector/aurora_stat_dml_activity_test.go @@ -0,0 +1,86 @@ +// Copyright 2025 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" + "github.com/smartystreets/goconvey/convey" +) + +func TestAuroraStatDMLActivityCollector(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} + + cols := []string{ + "datid", "datname", + "select_count", "select_latency_microsecs", + "insert_count", "insert_latency_microsecs", + "update_count", "update_latency_microsecs", + "delete_count", "delete_latency_microsecs", + } + mock.ExpectQuery(sanitizeQuery(auroraStatDMLActivityQuery)). + WillReturnRows(sqlmock.NewRows(cols). + AddRow("14007", "postgres", 178961, 66716329, 171065, 28876649, 519538, 1454579206167, 1, 53027). + AddRow("16384", "rdsadmin", 2346623, 1211703821, 4297518, 817184554, 0, 0, 0, 0)) + + ch := make(chan prometheus.Metric) + go func() { + defer close(ch) + c := AuroraStatDMLActivityCollector{excludeDatabases: []string{"rdsadmin"}} + if err := c.Update(context.Background(), inst, ch); err != nil { + t.Errorf("Error calling Update: %s", err) + } + }() + + got := 0 + for range ch { + got++ + } + convey.Convey("2 metrics × 4 operations × 1 db = 8", t, func() { + convey.So(got, convey.ShouldEqual, 8) + if err := mock.ExpectationsWereMet(); err != nil { + t.Errorf("Unfulfilled expectations: %s", err) + } + }) +} + +func TestAuroraStatDMLActivityCollectorNotAurora(t *testing.T) { + db, _, err := sqlmock.New() + if err != nil { + t.Fatalf("Error opening a stub db connection: %s", err) + } + defer db.Close() + + inst := &instance{db: db, isAurora: false} + + ch := make(chan prometheus.Metric) + go func() { + defer close(ch) + c := AuroraStatDMLActivityCollector{} + if err := c.Update(context.Background(), inst, ch); err != ErrNoData { + t.Errorf("Expected ErrNoData, got: %v", err) + } + }() + for range ch { + } +} diff --git a/collector/aurora_stat_logical_wal_cache.go b/collector/aurora_stat_logical_wal_cache.go new file mode 100644 index 000000000..5daa4bf03 --- /dev/null +++ b/collector/aurora_stat_logical_wal_cache.go @@ -0,0 +1,100 @@ +// Copyright 2025 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" +) + +// aurora_stat_logical_wal_cache() exposes per-replication-slot WAL cache usage. +// Available in Aurora PostgreSQL 11.17+, 12.12+, 13.8+, 14.7+, 15.2+. +const auroraStatLogicalWalCacheSubsystem = "aurora_stat_logical_wal_cache" + +func init() { + registerCollector("aurora_stat_logical_wal_cache", defaultDisabled, NewAuroraStatLogicalWalCacheCollector) +} + +type AuroraStatLogicalWalCacheCollector struct{} + +func NewAuroraStatLogicalWalCacheCollector(collectorConfig) (Collector, error) { + return &AuroraStatLogicalWalCacheCollector{}, nil +} + +var ( + auroraLogicalWalCacheHitDesc = prometheus.NewDesc( + prometheus.BuildFQName(namespace, auroraStatLogicalWalCacheSubsystem, "cache_hits_total"), + "Total number of WAL cache hits since last reset, per replication slot.", + []string{"slot_name"}, nil, + ) + auroraLogicalWalCacheMissDesc = prometheus.NewDesc( + prometheus.BuildFQName(namespace, auroraStatLogicalWalCacheSubsystem, "cache_misses_total"), + "Total number of WAL cache misses since last reset, per replication slot.", + []string{"slot_name"}, nil, + ) + auroraLogicalWalCacheBlksReadDesc = prometheus.NewDesc( + prometheus.BuildFQName(namespace, auroraStatLogicalWalCacheSubsystem, "blocks_read_total"), + "Total number of WAL cache read requests, per 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 + } + rows, err := instance.getDB().QueryContext(ctx, auroraStatLogicalWalCacheQuery) + if err != nil { + return err + } + defer rows.Close() + + var found bool + for rows.Next() { + found = true + + var name string + var cacheHit, cacheMiss, blksRead sql.NullInt64 + if err := rows.Scan(&name, &cacheHit, &cacheMiss, &blksRead); err != nil { + return err + } + + if cacheHit.Valid { + ch <- prometheus.MustNewConstMetric(auroraLogicalWalCacheHitDesc, prometheus.CounterValue, float64(cacheHit.Int64), name) + } + if cacheMiss.Valid { + ch <- prometheus.MustNewConstMetric(auroraLogicalWalCacheMissDesc, prometheus.CounterValue, float64(cacheMiss.Int64), name) + } + if blksRead.Valid { + ch <- prometheus.MustNewConstMetric(auroraLogicalWalCacheBlksReadDesc, prometheus.CounterValue, float64(blksRead.Int64), name) + } + } + + 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 000000000..06c69d193 --- /dev/null +++ b/collector/aurora_stat_logical_wal_cache_test.go @@ -0,0 +1,79 @@ +// Copyright 2025 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" + "github.com/smartystreets/goconvey/convey" +) + +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} + + mock.ExpectQuery(sanitizeQuery(auroraStatLogicalWalCacheQuery)). + WillReturnRows(sqlmock.NewRows([]string{"name", "cache_hit", "cache_miss", "blks_read"}). + AddRow("slot1", 24, 0, 24). + AddRow("slot2", 1, 0, 1)) + + 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 Update: %s", err) + } + }() + + got := 0 + for range ch { + got++ + } + convey.Convey("3 metrics × 2 rows = 6", t, func() { + convey.So(got, convey.ShouldEqual, 6) + if err := mock.ExpectationsWereMet(); err != nil { + t.Errorf("Unfulfilled expectations: %s", err) + } + }) +} + +func TestAuroraStatLogicalWalCacheCollectorNotAurora(t *testing.T) { + db, _, err := sqlmock.New() + if err != nil { + t.Fatalf("Error opening a stub db connection: %s", err) + } + defer db.Close() + + inst := &instance{db: db, isAurora: false} + + ch := make(chan prometheus.Metric) + go func() { + defer close(ch) + c := AuroraStatLogicalWalCacheCollector{} + if err := c.Update(context.Background(), inst, ch); err != ErrNoData { + t.Errorf("Expected ErrNoData, got: %v", err) + } + }() + for range ch { + } +} diff --git a/collector/aurora_stat_optimized_reads_cache.go b/collector/aurora_stat_optimized_reads_cache.go new file mode 100644 index 000000000..9291aed8d --- /dev/null +++ b/collector/aurora_stat_optimized_reads_cache.go @@ -0,0 +1,70 @@ +// Copyright 2025 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" +) + +// aurora_stat_optimized_reads_cache() reports Aurora Optimized Reads (tiered +// SSD-backed) cache sizing. Available in Aurora PostgreSQL 14.9+ / 15.4+. +const auroraStatOptimizedReadsCacheSubsystem = "aurora_stat_optimized_reads_cache" + +func init() { + registerCollector("aurora_stat_optimized_reads_cache", defaultDisabled, NewAuroraStatOptimizedReadsCacheCollector) +} + +type AuroraStatOptimizedReadsCacheCollector struct{} + +func NewAuroraStatOptimizedReadsCacheCollector(collectorConfig) (Collector, error) { + return &AuroraStatOptimizedReadsCacheCollector{}, nil +} + +var ( + auroraOrcTotalSizeDesc = prometheus.NewDesc( + prometheus.BuildFQName(namespace, auroraStatOptimizedReadsCacheSubsystem, "total_size_bytes"), + "Total optimized reads cache size in bytes.", + nil, nil, + ) + auroraOrcUsedSizeDesc = prometheus.NewDesc( + prometheus.BuildFQName(namespace, auroraStatOptimizedReadsCacheSubsystem, "used_size_bytes"), + "Used page size in optimized reads cache in bytes.", + nil, nil, + ) + + auroraStatOptimizedReadsCacheQuery = `SELECT total_size, used_size FROM aurora_stat_optimized_reads_cache()` +) + +func (c AuroraStatOptimizedReadsCacheCollector) Update(ctx context.Context, instance *instance, ch chan<- prometheus.Metric) error { + if !instance.isAurora { + return ErrNoData + } + row := instance.getDB().QueryRowContext(ctx, auroraStatOptimizedReadsCacheQuery) + + var totalSize, usedSize sql.NullInt64 + if err := row.Scan(&totalSize, &usedSize); err != nil { + return err + } + + if totalSize.Valid { + ch <- prometheus.MustNewConstMetric(auroraOrcTotalSizeDesc, prometheus.GaugeValue, float64(totalSize.Int64)) + } + if usedSize.Valid { + ch <- prometheus.MustNewConstMetric(auroraOrcUsedSizeDesc, prometheus.GaugeValue, float64(usedSize.Int64)) + } + return nil +} diff --git a/collector/aurora_stat_optimized_reads_cache_test.go b/collector/aurora_stat_optimized_reads_cache_test.go new file mode 100644 index 000000000..e631f3cd0 --- /dev/null +++ b/collector/aurora_stat_optimized_reads_cache_test.go @@ -0,0 +1,77 @@ +// Copyright 2025 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" + "github.com/smartystreets/goconvey/convey" +) + +func TestAuroraStatOptimizedReadsCacheCollector(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} + + mock.ExpectQuery(sanitizeQuery(auroraStatOptimizedReadsCacheQuery)). + WillReturnRows(sqlmock.NewRows([]string{"total_size", "used_size"}).AddRow(1131705600000, 1046771367936)) + + ch := make(chan prometheus.Metric) + go func() { + defer close(ch) + c := AuroraStatOptimizedReadsCacheCollector{} + if err := c.Update(context.Background(), inst, ch); err != nil { + t.Errorf("Error calling Update: %s", err) + } + }() + + got := 0 + for range ch { + got++ + } + convey.Convey("Two metrics emitted", t, func() { + convey.So(got, convey.ShouldEqual, 2) + if err := mock.ExpectationsWereMet(); err != nil { + t.Errorf("Unfulfilled expectations: %s", err) + } + }) +} + +func TestAuroraStatOptimizedReadsCacheCollectorNotAurora(t *testing.T) { + db, _, err := sqlmock.New() + if err != nil { + t.Fatalf("Error opening a stub db connection: %s", err) + } + defer db.Close() + + inst := &instance{db: db, isAurora: false} + + ch := make(chan prometheus.Metric) + go func() { + defer close(ch) + c := AuroraStatOptimizedReadsCacheCollector{} + if err := c.Update(context.Background(), inst, ch); err != ErrNoData { + t.Errorf("Expected ErrNoData, got: %v", err) + } + }() + for range ch { + } +} diff --git a/collector/collector_test.go b/collector/collector_test.go index 984b2c0ff..fd5cbc681 100644 --- a/collector/collector_test.go +++ b/collector/collector_test.go @@ -13,6 +13,8 @@ package collector import ( + "context" + "database/sql" "strings" "testing" "time" @@ -133,3 +135,96 @@ func TestWithConnectionTimeout(t *testing.T) { t.Errorf("there were unfulfilled exceptions: %s", err) } } + +// TestIsNoDataError pins the trivial identity of ErrNoData. The wrapper +// exists so callers do not depend on the package-private sentinel value +// directly; if that contract drifts, this test fails immediately. +func TestIsNoDataError(t *testing.T) { + if !IsNoDataError(ErrNoData) { + t.Error("IsNoDataError(ErrNoData) must be true") + } + if IsNoDataError(nil) { + t.Error("IsNoDataError(nil) must be false") + } + if IsNoDataError(errFnDoesNotExist) { + t.Error("IsNoDataError must reject unrelated errors") + } +} + +// TestInt32 covers the small helper that drops sql.NullInt32 values to +// float64 with a NaN-style zero default. The two interesting cases are +// "valid" (returns the value) and "invalid" (returns 0). +func TestInt32(t *testing.T) { + if got := Int32(sql.NullInt32{Int32: 42, Valid: true}); got != 42 { + t.Errorf("Int32(valid=42) = %v, want 42", got) + } + if got := Int32(sql.NullInt32{Valid: false}); got != 0 { + t.Errorf("Int32(invalid) = %v, want 0", got) + } +} + +// TestExecuteSuccessfulCollectorEmitsSuccessOne and the companion +// "failed" / "no data" tests verify the execute() wrapper records the +// right pg_scrape_collector_success value depending on what the +// Collector.Update returns. +func TestExecuteSuccessfulCollectorEmitsSuccessOne(t *testing.T) { + got := runExecuteAndReadSuccess(t, func(ctx context.Context, inst *instance, ch chan<- prometheus.Metric) error { + return nil + }) + if got != 1 { + t.Errorf("scrape_collector_success = %v, want 1", got) + } +} + +func TestExecuteFailedCollectorEmitsSuccessZero(t *testing.T) { + got := runExecuteAndReadSuccess(t, func(ctx context.Context, inst *instance, ch chan<- prometheus.Metric) error { + return errFnDoesNotExist + }) + if got != 0 { + t.Errorf("scrape_collector_success = %v, want 0", got) + } +} + +func TestExecuteNoDataCollectorEmitsSuccessZero(t *testing.T) { + got := runExecuteAndReadSuccess(t, func(ctx context.Context, inst *instance, ch chan<- prometheus.Metric) error { + return ErrNoData + }) + if got != 0 { + t.Errorf("scrape_collector_success = %v, want 0", got) + } +} + +// runExecuteAndReadSuccess wraps the Collector under test in a tiny +// adapter, drains the duration metric, and returns the success metric so +// the caller can assert against it. +func runExecuteAndReadSuccess(t *testing.T, update func(ctx context.Context, inst *instance, ch chan<- prometheus.Metric) error) float64 { + t.Helper() + ch := make(chan prometheus.Metric, 4) + c := updateFn(update) + execute(context.Background(), "fake", c, &instance{}, ch, promslog.NewNopLogger()) + close(ch) + + var success float64 = -1 + for m := range ch { + mr := readMetric(m) + // Two metrics emitted: duration_seconds and success. We only care + // about success here. + desc := m.Desc().String() + if strings.Contains(desc, "collector_success") { + success = mr.value + } + } + if success == -1 { + t.Fatal("scrape_collector_success metric was not emitted") + } + return success +} + +// updateFn lets a test inline its Update implementation without declaring +// a struct per case. +type updateFn func(ctx context.Context, inst *instance, ch chan<- prometheus.Metric) error + +func (f updateFn) Update(ctx context.Context, inst *instance, ch chan<- prometheus.Metric) error { + return f(ctx, inst, ch) +} + diff --git a/collector/instance.go b/collector/instance.go index a365697d6..888d65669 100644 --- a/collector/instance.go +++ b/collector/instance.go @@ -17,19 +17,36 @@ import ( "database/sql" "fmt" "regexp" + "sync" "github.com/blang/semver/v4" ) +// auroraProbe caches the result of detectAurora() so the aurora_version() +// query runs at most once per process. The parent instance and every copy +// produced by copy() share the same pointer. +type auroraProbe struct { + once sync.Once + isAurora bool +} + type instance struct { dsn string db *sql.DB version semver.Version + + // isAurora is set by setup() via the shared auroraProbe (one-shot + // SELECT aurora_version() per process). Aurora-specific collectors + // gate on this field; on non-Aurora servers they short-circuit to + // ErrNoData. + isAurora bool + auroraProbe *auroraProbe } func newInstance(dsn string) (*instance, error) { i := &instance{ - dsn: dsn, + dsn: dsn, + auroraProbe: &auroraProbe{}, } // "Create" a database handle to verify the DSN provided is valid. @@ -46,7 +63,8 @@ func newInstance(dsn string) (*instance, error) { // copy returns a copy of the instance. func (i *instance) copy() *instance { return &instance{ - dsn: i.dsn, + dsn: i.dsn, + auroraProbe: i.auroraProbe, } } @@ -65,14 +83,36 @@ func (i *instance) setup() error { } else { i.version = version } + + if i.auroraProbe != nil { + i.auroraProbe.once.Do(func() { + i.auroraProbe.isAurora = detectAurora(i.db) + }) + i.isAurora = i.auroraProbe.isAurora + } return nil } +// detectAurora reports whether the connected server is Amazon Aurora +// PostgreSQL. It calls aurora_version(), an Aurora-only built-in: any +// error means we are not on Aurora. The check is best-effort and +// silently returns false on failure. +func detectAurora(db *sql.DB) bool { + var v string + return db.QueryRow("SELECT aurora_version()").Scan(&v) == nil +} + func (i *instance) getDB() *sql.DB { return i.db } func (i *instance) Close() error { + if i.db == nil { + // setup() was never called (or failed before sql.Open); nothing to + // close. This guard lets callers defer Close() right after + // construction without worrying about ordering. + return nil + } return i.db.Close() } diff --git a/collector/instance_test.go b/collector/instance_test.go new file mode 100644 index 000000000..2b7a2e974 --- /dev/null +++ b/collector/instance_test.go @@ -0,0 +1,297 @@ +// Copyright 2025 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 ( + "testing" + + "github.com/DATA-DOG/go-sqlmock" +) + +// TestNewInstanceAllocatesAuroraProbe verifies that every instance gets a +// non-nil auroraProbe at construction. Without it the setup() code path +// would silently skip Aurora detection. +func TestNewInstanceAllocatesAuroraProbe(t *testing.T) { + i, err := newInstance("postgres://user:pass@localhost:5432/db?sslmode=disable") + if err != nil { + t.Fatalf("newInstance: %v", err) + } + if i.auroraProbe == nil { + t.Fatal("auroraProbe must be allocated by newInstance") + } +} + +// TestInstanceCopySharesAuroraProbe is a regression test for the "detect +// every scrape" bug. The parent instance and every copy returned by copy() +// MUST share the same *auroraProbe pointer so that sync.Once collapses all +// detection attempts into one for the lifetime of the process. +func TestInstanceCopySharesAuroraProbe(t *testing.T) { + i, err := newInstance("postgres://user:pass@localhost:5432/db?sslmode=disable") + if err != nil { + t.Fatalf("newInstance: %v", err) + } + + c1 := i.copy() + c2 := i.copy() + c3 := c1.copy() // copy of a copy must still share the probe + + for name, c := range map[string]*instance{"c1": c1, "c2": c2, "c3": c3} { + if c.auroraProbe != i.auroraProbe { + t.Errorf("%s.auroraProbe pointer must equal parent's", name) + } + } +} + +// TestAuroraProbeRunsOnce verifies the sync.Once semantics on auroraProbe. +// Even if setup() is invoked many times (every scrape, in every copy), +// detectAurora() must run exactly once and the cached value is reused. +func TestAuroraProbeRunsOnce(t *testing.T) { + probe := &auroraProbe{} + + calls := 0 + for i := 0; i < 100; i++ { + probe.once.Do(func() { + calls++ + probe.isAurora = true + }) + } + if calls != 1 { + t.Errorf("once.Do should fire exactly once, fired %d times", calls) + } + if !probe.isAurora { + t.Error("cached isAurora value should persist after first call") + } +} + +// TestDetectAuroraTrue verifies detectAurora returns true when +// aurora_version() succeeds, mimicking an Aurora server. +func TestDetectAuroraTrue(t *testing.T) { + db, mock, err := sqlmock.New() + if err != nil { + t.Fatalf("sqlmock.New: %v", err) + } + defer db.Close() + + mock.ExpectQuery(`SELECT aurora_version\(\)`). + WillReturnRows(sqlmock.NewRows([]string{"aurora_version"}).AddRow("15.4")) + + if !detectAurora(db) { + t.Error("detectAurora must return true when aurora_version() succeeds") + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Errorf("unfulfilled expectations: %s", err) + } +} + +// TestDetectAuroraFalse verifies detectAurora returns false when +// aurora_version() errors (the function does not exist on plain PostgreSQL). +func TestDetectAuroraFalse(t *testing.T) { + db, mock, err := sqlmock.New() + if err != nil { + t.Fatalf("sqlmock.New: %v", err) + } + defer db.Close() + + mock.ExpectQuery(`SELECT aurora_version\(\)`). + WillReturnError(errFnDoesNotExist) + + if detectAurora(db) { + t.Error("detectAurora must return false when aurora_version() errors") + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Errorf("unfulfilled expectations: %s", err) + } +} + +// errFnDoesNotExist is a generic error mirroring the message plain +// PostgreSQL returns for an unknown function — we only need a non-nil error +// to drive the negative path. +var errFnDoesNotExist = &fakeErr{msg: `pq: function aurora_version() does not exist`} + +type fakeErr struct{ msg string } + +func (e *fakeErr) Error() string { return e.msg } + +// TestInstanceCopyPreservesDSN verifies that copy() carries over the DSN — +// it is required for the fresh per-scrape instance to reconnect to the +// right server. +func TestInstanceCopyPreservesDSN(t *testing.T) { + const dsn = "postgres://user:pass@example.invalid:5432/db?sslmode=disable" + i, err := newInstance(dsn) + if err != nil { + t.Fatalf("newInstance: %v", err) + } + + c := i.copy() + if c.dsn != dsn { + t.Errorf("copy.dsn = %q, want %q", c.dsn, dsn) + } + if c.db != nil { + t.Error("copy.db must be nil — setup() establishes a fresh connection") + } +} + +// TestInstanceGetDB ensures getDB() returns whatever was placed in the db +// field. It is a tiny accessor but the rest of the package depends on it +// returning the live connection set up during setup(). +func TestInstanceGetDB(t *testing.T) { + db, _, err := sqlmock.New() + if err != nil { + t.Fatalf("sqlmock.New: %v", err) + } + defer db.Close() + + i := &instance{db: db} + if got := i.getDB(); got != db { + t.Errorf("getDB() = %p, want %p", got, db) + } +} + +// TestQueryVersionFromSelect verifies the happy path: SELECT version() +// returns a "PostgreSQL X.Y.Z ..." string that the regex parses. +func TestQueryVersionFromSelect(t *testing.T) { + db, mock, err := sqlmock.New() + if err != nil { + t.Fatalf("sqlmock.New: %v", err) + } + defer db.Close() + + mock.ExpectQuery(`SELECT version\(\);`). + WillReturnRows(sqlmock.NewRows([]string{"version"}). + AddRow("PostgreSQL 16.4 on aarch64-unknown-linux-gnu, compiled by gcc 12.2.0, 64-bit")) + + v, err := queryVersion(db) + if err != nil { + t.Fatalf("queryVersion: %v", err) + } + if v.Major != 16 || v.Minor != 4 { + t.Errorf("queryVersion = %v, want 16.4.x", v) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Errorf("unfulfilled expectations: %s", err) + } +} + +// TestQueryVersionFallbackToServerVersion: when SELECT version() returns +// something the regex cannot parse, queryVersion must fall through to +// SHOW server_version and try the looser regex there. +func TestQueryVersionFallbackToServerVersion(t *testing.T) { + db, mock, err := sqlmock.New() + if err != nil { + t.Fatalf("sqlmock.New: %v", err) + } + defer db.Close() + + // First call returns an unparseable string (no leading word + version). + mock.ExpectQuery(`SELECT version\(\);`). + WillReturnRows(sqlmock.NewRows([]string{"version"}).AddRow("???")) + // Fallback path queries SHOW server_version. + mock.ExpectQuery(`SHOW server_version;`). + WillReturnRows(sqlmock.NewRows([]string{"server_version"}). + AddRow("13.3 (Debian 13.3-1.pgdg100+1)")) + + v, err := queryVersion(db) + if err != nil { + t.Fatalf("queryVersion: %v", err) + } + if v.Major != 13 || v.Minor != 3 { + t.Errorf("queryVersion = %v, want 13.3.x", v) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Errorf("unfulfilled expectations: %s", err) + } +} + +// TestQueryVersionBothUnparseable: when neither query yields a recognizable +// version, queryVersion returns a non-nil error to make the failure loud. +func TestQueryVersionBothUnparseable(t *testing.T) { + db, mock, err := sqlmock.New() + if err != nil { + t.Fatalf("sqlmock.New: %v", err) + } + defer db.Close() + + mock.ExpectQuery(`SELECT version\(\);`). + WillReturnRows(sqlmock.NewRows([]string{"version"}).AddRow("???")) + mock.ExpectQuery(`SHOW server_version;`). + WillReturnRows(sqlmock.NewRows([]string{"server_version"}).AddRow("???")) + + if _, err := queryVersion(db); err == nil { + t.Error("queryVersion must return an error when nothing parses") + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Errorf("unfulfilled expectations: %s", err) + } +} + +// TestQueryVersionFirstQueryFails: a raw SQL error on the first query is +// returned verbatim — we don't paper over connection errors as version +// parse failures. +func TestQueryVersionFirstQueryFails(t *testing.T) { + db, mock, err := sqlmock.New() + if err != nil { + t.Fatalf("sqlmock.New: %v", err) + } + defer db.Close() + + mock.ExpectQuery(`SELECT version\(\);`).WillReturnError(errFnDoesNotExist) + + if _, err := queryVersion(db); err == nil { + t.Error("queryVersion must propagate the underlying error") + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Errorf("unfulfilled expectations: %s", err) + } +} + +// TestSetupRunsAuroraDetection verifies that setup() probes aurora_version() +// once and caches the result on the shared auroraProbe. Detection is always +// on; aurora_* collectors gate on instance.isAurora at Update() time. +func TestSetupRunsAuroraDetection(t *testing.T) { + db, mock, err := sqlmock.New() + if err != nil { + t.Fatalf("sqlmock.New: %v", err) + } + defer db.Close() + + mock.ExpectQuery(`SELECT version\(\);`). + WillReturnRows(sqlmock.NewRows([]string{"version"}). + AddRow("PostgreSQL 15.4 on aarch64")) + mock.ExpectQuery(`SELECT aurora_version\(\)`). + WillReturnRows(sqlmock.NewRows([]string{"aurora_version"}).AddRow("15.4")) + + i := &instance{ + db: db, + auroraProbe: &auroraProbe{}, + } + v, err := queryVersion(i.db) + if err != nil { + t.Fatalf("queryVersion: %v", err) + } + i.version = v + + if i.auroraProbe != nil { + i.auroraProbe.once.Do(func() { + i.auroraProbe.isAurora = detectAurora(i.db) + }) + i.isAurora = i.auroraProbe.isAurora + } + + if !i.isAurora { + t.Error("isAurora must be true after detect succeeds") + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Errorf("unfulfilled expectations: %s", err) + } +} diff --git a/collector/probe_test.go b/collector/probe_test.go new file mode 100644 index 000000000..e31d61ada --- /dev/null +++ b/collector/probe_test.go @@ -0,0 +1,133 @@ +// Copyright 2025 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 ( + "testing" + + "github.com/prometheus-community/postgres_exporter/config" + "github.com/prometheus/client_golang/prometheus" + "github.com/prometheus/common/promslog" +) + +// newProbeTestDSN returns a DSN suitable for probe-collector construction. +// The exported ConfigureTarget path is the only way for tests to obtain +// a config.DSN value because the parser is package-private. +func newProbeTestDSN(t *testing.T) config.DSN { + t.Helper() + auth := config.AuthModule{ + Type: "userpass", + UserPass: config.UserPass{Username: "user", Password: "pass"}, + } + dsn, err := auth.ConfigureTarget("postgres://localhost:5432/postgres?sslmode=disable") + if err != nil { + t.Fatalf("ConfigureTarget: %v", err) + } + return dsn +} + +// TestNewProbeCollectorAllocatesAuroraProbe verifies that the per-request +// instance gets a non-nil auroraProbe so detectAurora() can run on the +// first scrape (and exactly once for the lifetime of this ProbeCollector). +func TestNewProbeCollectorAllocatesAuroraProbe(t *testing.T) { + logger := promslog.NewNopLogger() + registry := prometheus.NewRegistry() + + pc, err := NewProbeCollector(logger, nil, registry, newProbeTestDSN(t)) + if err != nil { + t.Fatalf("NewProbeCollector: %v", err) + } + t.Cleanup(func() { _ = pc.Close() }) + + if pc.instance == nil { + t.Fatal("ProbeCollector.instance must not be nil") + } + if pc.instance.auroraProbe == nil { + t.Error("instance.auroraProbe must be allocated for the probe path") + } +} + +// TestNewProbeCollectorRespectsCollectorState verifies that NewProbeCollector +// only wires up collectors whose state flag is true. It does so without +// caring about specific collector names — we just flip a known collector's +// state and observe whether it ends up in the resulting collectors map. +func TestNewProbeCollectorRespectsCollectorState(t *testing.T) { + // Snapshot to restore. + snapshot := make(map[string]bool, len(collectorState)) + for k, v := range collectorState { + snapshot[k] = *v + } + t.Cleanup(func() { + for k, v := range snapshot { + *collectorState[k] = v + } + }) + + // Disable everything, then enable exactly one collector. + for _, state := range collectorState { + *state = false + } + target := "database" + if _, ok := collectorState[target]; !ok { + t.Skipf("collector %q not registered, skipping", target) + } + *collectorState[target] = true + + logger := promslog.NewNopLogger() + registry := prometheus.NewRegistry() + pc, err := NewProbeCollector(logger, nil, registry, newProbeTestDSN(t)) + if err != nil { + t.Fatalf("NewProbeCollector: %v", err) + } + t.Cleanup(func() { _ = pc.Close() }) + + if _, ok := pc.collectors[target]; !ok { + t.Errorf("expected collector %q to be wired up, got %v", target, keysOf(pc.collectors)) + } + for name := range pc.collectors { + if name != target { + t.Errorf("unexpected collector %q wired up", name) + } + } +} + +// TestProbeCollectorDescribeIsEmpty pins the documented behavior: probe +// collectors emit their metrics via MustNewConstMetric and intentionally +// produce nothing from Describe(). If that contract changes we want a +// loud failure here so we revisit prometheus registration semantics. +func TestProbeCollectorDescribeIsEmpty(t *testing.T) { + logger := promslog.NewNopLogger() + registry := prometheus.NewRegistry() + + pc, err := NewProbeCollector(logger, nil, registry, newProbeTestDSN(t)) + if err != nil { + t.Fatalf("NewProbeCollector: %v", err) + } + t.Cleanup(func() { _ = pc.Close() }) + + ch := make(chan *prometheus.Desc, 8) + pc.Describe(ch) + close(ch) + for d := range ch { + t.Errorf("Describe emitted an unexpected descriptor: %v", d) + } +} + +func keysOf(m map[string]Collector) []string { + ks := make([]string, 0, len(m)) + for k := range m { + ks = append(ks, k) + } + return ks +}