From 8fe5f2098da26ebe2e26e57cb66511ca1c19ec17 Mon Sep 17 00:00:00 2001 From: Gabriel Antunes Date: Wed, 9 Sep 2026 16:07:40 -0300 Subject: [PATCH 01/11] Add postgres table_stats/index_stats collectors for missing/unused-index detection Adds two opt-in collectors, disabled by default and enabled via enable_collectors: table_stats emits pg_stat_user_tables_seq_scan/idx_scan/ n_live_tup; index_stats emits pg_stat_user_indexes_idx_scan_total, pg_index_properties{is_primary}, and pg_index_size_bytes. Both fan out across every database the connection can reach (reusing schema_details' discovery/connection mechanism, extracted into a shared multi_database.go), fixing the vendored postgres_exporter's single-DSN limitation for these system views natively rather than waiting on the unmerged upstream prometheus-community/postgres_exporter#1378. Metric names/labels match the existing pg_stat_user_tables_* fields, and, for the new index metrics, the shape proposed by the unmerged upstream prometheus-community/postgres_exporter#1071. Also fixes a redundant-connection issue in the multi-database fan-out (connectToDatabase): it always opened a fresh connection via factory(), even for the one database that's already the database the initial connection points to, since a freshly-sql.Open'd *sql.DB is never pointer-equal to the initial one. It now detects that case up front and reuses the initial connection directly. Validated against a live db-o11y-postgres-16 test instance hosting real books_store data: every emitted series across both collectors matched the database source exactly. Co-Authored-By: Claude Sonnet 5 --- .../database_observability.postgres.md | 2 + .../postgres/collector/dsn.go | 11 ++ .../postgres/collector/index_stats.go | 173 ++++++++++++++++++ .../postgres/collector/index_stats_test.go | 66 +++++++ .../postgres/collector/logs.go | 2 +- .../postgres/collector/multi_database.go | 71 +++++++ .../postgres/collector/multi_database_test.go | 57 ++++++ .../postgres/collector/schema_details.go | 42 +---- .../postgres/collector/table_stats.go | 164 +++++++++++++++++ .../postgres/collector/table_stats_test.go | 62 +++++++ .../postgres/component.go | 38 ++++ .../postgres/component_test.go | 18 ++ 12 files changed, 669 insertions(+), 37 deletions(-) create mode 100644 internal/component/database_observability/postgres/collector/index_stats.go create mode 100644 internal/component/database_observability/postgres/collector/index_stats_test.go create mode 100644 internal/component/database_observability/postgres/collector/multi_database.go create mode 100644 internal/component/database_observability/postgres/collector/multi_database_test.go create mode 100644 internal/component/database_observability/postgres/collector/table_stats.go create mode 100644 internal/component/database_observability/postgres/collector/table_stats_test.go diff --git a/docs/sources/reference/components/database_observability/database_observability.postgres.md b/docs/sources/reference/components/database_observability/database_observability.postgres.md index 262c19d873c..035f1b4ee02 100644 --- a/docs/sources/reference/components/database_observability/database_observability.postgres.md +++ b/docs/sources/reference/components/database_observability/database_observability.postgres.md @@ -56,6 +56,8 @@ The following collectors are configurable: | Name | Description | Enabled by default | |------------------|-----------------------------------------------------------------------|--------------------| | `explain_plans` | Collect query explain plans. | yes | +| `table_stats` | Collect table scan statistics from `pg_stat_user_tables`, across every database the connection can reach, for missing-index detection. | no | +| `index_stats` | Collect per-index usage statistics from `pg_stat_user_indexes`, across every database the connection can reach, for unused-index detection. | no | | `logs` | Process PostgreSQL logs and export error metrics. | yes | | `query_details` | Collect queries information. | yes | | `query_samples` | Collect query samples and wait events information. | yes | diff --git a/internal/component/database_observability/postgres/collector/dsn.go b/internal/component/database_observability/postgres/collector/dsn.go index e391582c8de..82347f1c27e 100644 --- a/internal/component/database_observability/postgres/collector/dsn.go +++ b/internal/component/database_observability/postgres/collector/dsn.go @@ -30,3 +30,14 @@ func replaceDatabaseNameInDSN(dsn, newDatabaseName string) (string, error) { newDSN := matches[1] + newDatabaseName + matches[3] return newDSN, nil } + +// databaseNameFromDSN extracts the database name a DSN already points to, so +// callers fanning out per-database can tell whether a target database is the +// one an existing connection already uses. +func databaseNameFromDSN(dsn string) (string, error) { + matches := dsnParseRegex.FindStringSubmatch(dsn) + if len(matches) < 4 { + return "", errors.New("failed to parse DSN for database name") + } + return matches[2], nil +} diff --git a/internal/component/database_observability/postgres/collector/index_stats.go b/internal/component/database_observability/postgres/collector/index_stats.go new file mode 100644 index 00000000000..2641c0bd29e --- /dev/null +++ b/internal/component/database_observability/postgres/collector/index_stats.go @@ -0,0 +1,173 @@ +package collector + +import ( + "context" + "database/sql" + "log/slog" + + "github.com/prometheus/client_golang/prometheus" + "go.uber.org/atomic" +) + +// IndexStatsCollector emits the minimal set of per-index metrics needed for +// the unused-index KG insight, from pg_stat_user_indexes, scoped to every +// database the connection can reach rather than only the one named in the DSN. +const IndexStatsCollector = "index_stats" + +const selectIndexUsageStats = ` + SELECT + s.schemaname, + s.relname, + s.indexrelname, + s.idx_scan, + i.indisprimary, + pg_relation_size(s.indexrelid) AS index_size_bytes + FROM pg_stat_user_indexes s + JOIN pg_index i ON i.indexrelid = s.indexrelid` + +var indexLabels = []string{labelDatname, "schemaname", "relname", "indexrelname"} + +var ( + // Named to match the metrics proposed by the (currently unmerged) upstream + // prometheus-community/postgres_exporter#1071, so that adopting the real + // upstream collector later, if/when it lands, needs no rule changes. + indexUsageIdxScanTotalDesc = prometheus.NewDesc( + prometheus.BuildFQName("pg", "stat_user_indexes", "idx_scan_total"), + "Number of index scans initiated on this index", + indexLabels, nil, + ) + indexPropertiesDesc = prometheus.NewDesc( + "pg_index_properties", + "Properties of an index; a constant 1 with is_primary set to whether the index backs a primary key", + append(append([]string{}, indexLabels...), "is_primary"), nil, + ) + indexSizeBytesDesc = prometheus.NewDesc( + "pg_index_size_bytes", + "Total disk space used by this index, in bytes", + indexLabels, nil, + ) +) + +type IndexStatsArguments struct { + DB *sql.DB + DSN string + ExcludeDatabases []string + Registry *prometheus.Registry + + Logger *slog.Logger + + dbConnectionFactory databaseConnectionFactory +} + +type IndexStats struct { + initialConnection *sql.DB + dbDSN string + dbConnectionFactory databaseConnectionFactory + excludeDatabases []string + registry *prometheus.Registry + + logger *slog.Logger + running *atomic.Bool +} + +func NewIndexStats(args IndexStatsArguments) (*IndexStats, error) { + factory := args.dbConnectionFactory + if factory == nil { + factory = defaultDbConnectionFactory + } + + return &IndexStats{ + initialConnection: args.DB, + dbDSN: args.DSN, + dbConnectionFactory: factory, + excludeDatabases: args.ExcludeDatabases, + registry: args.Registry, + logger: args.Logger.With("collector", IndexStatsCollector), + running: &atomic.Bool{}, + }, nil +} + +func (c *IndexStats) Name() string { + return IndexStatsCollector +} + +func (c *IndexStats) Start(_ context.Context) error { + if err := c.registry.Register(c); err != nil { + return err + } + c.running.Store(true) + return nil +} + +func (c *IndexStats) Stopped() bool { + return !c.running.Load() +} + +func (c *IndexStats) Stop() { + c.registry.Unregister(c) + c.running.Store(false) +} + +// Describe implements prometheus.Collector. +func (c *IndexStats) Describe(ch chan<- *prometheus.Desc) { + ch <- indexUsageIdxScanTotalDesc + ch <- indexPropertiesDesc + ch <- indexSizeBytesDesc +} + +// Collect implements prometheus.Collector. It runs synchronously at scrape +// time, fanning out to every database the connection can reach. +func (c *IndexStats) Collect(ch chan<- prometheus.Metric) { + ctx := context.Background() + + databases, err := discoverDatabases(ctx, c.initialConnection, c.excludeDatabases) + if err != nil { + c.logger.Error("failed to discover databases", "err", err) + return + } + + for _, dbName := range databases { + conn, closeConn, err := connectToDatabase(c.dbDSN, dbName, c.dbConnectionFactory, c.initialConnection) + if err != nil { + c.logger.Error("failed to connect to database", "datname", dbName, "err", err) + continue + } + + c.collectIndexUsageStats(ctx, dbName, conn, ch) + + closeConn() + } +} + +func (c *IndexStats) collectIndexUsageStats(ctx context.Context, dbName string, conn *sql.DB, ch chan<- prometheus.Metric) { + rows, err := conn.QueryContext(ctx, selectIndexUsageStats) + if err != nil { + c.logger.Error("failed to query pg_stat_user_indexes", "datname", dbName, "err", err) + return + } + defer rows.Close() + + for rows.Next() { + var schemaname, relname, indexrelname string + var idxScan, indexSizeBytes sql.NullInt64 + var isPrimary bool + + if err := rows.Scan(&schemaname, &relname, &indexrelname, &idxScan, &isPrimary, &indexSizeBytes); err != nil { + c.logger.Error("failed to scan pg_stat_user_indexes row", "datname", dbName, "err", err) + return + } + + isPrimaryLabel := "false" + if isPrimary { + isPrimaryLabel = "true" + } + + ch <- prometheus.MustNewConstMetric(indexUsageIdxScanTotalDesc, prometheus.CounterValue, float64(idxScan.Int64), dbName, schemaname, relname, indexrelname) + ch <- prometheus.MustNewConstMetric(indexPropertiesDesc, prometheus.GaugeValue, 1, dbName, schemaname, relname, indexrelname, isPrimaryLabel) + ch <- prometheus.MustNewConstMetric(indexSizeBytesDesc, prometheus.GaugeValue, float64(indexSizeBytes.Int64), dbName, schemaname, relname, indexrelname) + } + + if err := rows.Err(); err != nil { + c.logger.Error("error iterating pg_stat_user_indexes rows", "datname", dbName, "err", err) + } +} diff --git a/internal/component/database_observability/postgres/collector/index_stats_test.go b/internal/component/database_observability/postgres/collector/index_stats_test.go new file mode 100644 index 00000000000..29c21cba6bf --- /dev/null +++ b/internal/component/database_observability/postgres/collector/index_stats_test.go @@ -0,0 +1,66 @@ +package collector + +import ( + "database/sql" + "fmt" + "strings" + "testing" + + sqlmock "github.com/DATA-DOG/go-sqlmock" + "github.com/prometheus/client_golang/prometheus" + "github.com/prometheus/client_golang/prometheus/testutil" + "github.com/stretchr/testify/require" + + "github.com/grafana/alloy/internal/util" +) + +func TestIndexStats(t *testing.T) { + db, mock, err := sqlmock.New(sqlmock.QueryMatcherOption(sqlmock.QueryMatcherEqual)) + require.NoError(t, err) + defer db.Close() + + registry := prometheus.NewRegistry() + + c, err := NewIndexStats(IndexStatsArguments{ + DB: db, + DSN: "postgres://user:pass@localhost:5432/books_store", + ExcludeDatabases: nil, + Registry: registry, + Logger: util.TestAlloyLogger(t).Slog(), + dbConnectionFactory: func(dsn string) (*sql.DB, error) { + return db, nil + }, + }) + require.NoError(t, err) + + require.NoError(t, c.Start(t.Context())) + defer c.Stop() + + mock.ExpectQuery(fmt.Sprintf(selectAllDatabases, exclusionClause)).WithoutArgs().RowsWillBeClosed(). + WillReturnRows(sqlmock.NewRows([]string{"datname"}).AddRow("books_store")) + + mock.ExpectQuery(selectIndexUsageStats).WithoutArgs().RowsWillBeClosed(). + WillReturnRows( + sqlmock.NewRows([]string{"schemaname", "relname", "indexrelname", "idx_scan", "indisprimary", "index_size_bytes"}). + AddRow("public", "books", "books_pkey", 184000000, true, 65536). + AddRow("public", "books", "idx_books_title", 0, false, 32768), + ) + + expected := ` + # HELP pg_stat_user_indexes_idx_scan_total Number of index scans initiated on this index + # TYPE pg_stat_user_indexes_idx_scan_total counter + pg_stat_user_indexes_idx_scan_total{datname="books_store",indexrelname="books_pkey",relname="books",schemaname="public"} 1.84e+08 + pg_stat_user_indexes_idx_scan_total{datname="books_store",indexrelname="idx_books_title",relname="books",schemaname="public"} 0 + # HELP pg_index_properties Properties of an index; a constant 1 with is_primary set to whether the index backs a primary key + # TYPE pg_index_properties gauge + pg_index_properties{datname="books_store",indexrelname="books_pkey",is_primary="true",relname="books",schemaname="public"} 1 + pg_index_properties{datname="books_store",indexrelname="idx_books_title",is_primary="false",relname="books",schemaname="public"} 1 + # HELP pg_index_size_bytes Total disk space used by this index, in bytes + # TYPE pg_index_size_bytes gauge + pg_index_size_bytes{datname="books_store",indexrelname="books_pkey",relname="books",schemaname="public"} 65536 + pg_index_size_bytes{datname="books_store",indexrelname="idx_books_title",relname="books",schemaname="public"} 32768 +` + + require.NoError(t, testutil.CollectAndCompare(registry, strings.NewReader(expected))) + require.NoError(t, mock.ExpectationsWereMet()) +} diff --git a/internal/component/database_observability/postgres/collector/logs.go b/internal/component/database_observability/postgres/collector/logs.go index 0d06190a87a..d5bd998b7b5 100644 --- a/internal/component/database_observability/postgres/collector/logs.go +++ b/internal/component/database_observability/postgres/collector/logs.go @@ -180,7 +180,7 @@ func (l *Logs) initMetrics() { Name: "pg_errors_total", Help: "Number of log lines with errors by severity and sql state code", }, - []string{"severity", "sqlstate", "sqlstate_class", "datname", "user"}, + []string{"severity", "sqlstate", "sqlstate_class", labelDatname, "user"}, ) l.parseErrors = prometheus.NewCounter( diff --git a/internal/component/database_observability/postgres/collector/multi_database.go b/internal/component/database_observability/postgres/collector/multi_database.go new file mode 100644 index 00000000000..cfe0025a58a --- /dev/null +++ b/internal/component/database_observability/postgres/collector/multi_database.go @@ -0,0 +1,71 @@ +package collector + +import ( + "context" + "database/sql" + "fmt" +) + +// discoverDatabases lists the databases on the Postgres instance that the +// current connection is allowed to CONNECT to, via the pg_database catalog +// view (readable from any single connection). Callers use this to fan out +// per-database connections, since most stat views (e.g. pg_stat_user_tables, +// pg_stat_user_indexes) only ever report on the database a connection is +// actually established to. +func discoverDatabases(ctx context.Context, conn *sql.DB, excludeDatabases []string) ([]string, error) { + query := fmt.Sprintf(selectAllDatabases, buildExcludedDatabasesClause(excludeDatabases)) + rows, err := conn.QueryContext(ctx, query) + if err != nil { + return nil, fmt.Errorf("failed to discover databases: %w", err) + } + defer rows.Close() + + var databases []string + for rows.Next() { + var datname string + if err := rows.Scan(&datname); err != nil { + return nil, fmt.Errorf("failed to scan database name: %w", err) + } + databases = append(databases, datname) + } + + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("error iterating database rows: %w", err) + } + + return databases, nil +} + +// connectToDatabase opens a connection to dbName by rewriting the database +// name in dsn, using factory. If dbName is the database dsn (and so initial) +// already points to, initial is reused directly instead of opening a +// redundant connection: with the real sql.Open-based factory, a freshly +// opened *sql.DB is never pointer-equal to initial even for an identical +// DSN, so skipping the redundant open/close has to happen here, up front. +// The returned closeFn closes the connection unless it is initial (in which +// case closing it is the caller's responsibility elsewhere). +func connectToDatabase(dsn, dbName string, factory databaseConnectionFactory, initial *sql.DB) (conn *sql.DB, closeFn func(), err error) { + noopClose := func() {} + + if currentDBName, err := databaseNameFromDSN(dsn); err == nil && currentDBName == dbName { + return initial, noopClose, nil + } + + databaseDSN, err := replaceDatabaseNameInDSN(dsn, dbName) + if err != nil { + return nil, nil, fmt.Errorf("failed to create DSN for database %s: %w", dbName, err) + } + + conn, err = factory(databaseDSN) + if err != nil { + return nil, nil, fmt.Errorf("failed to create connection to database %s: %w", dbName, err) + } + + closeFn = func() { + if conn != initial { + conn.Close() + } + } + + return conn, closeFn, nil +} diff --git a/internal/component/database_observability/postgres/collector/multi_database_test.go b/internal/component/database_observability/postgres/collector/multi_database_test.go new file mode 100644 index 00000000000..629bb0cb54a --- /dev/null +++ b/internal/component/database_observability/postgres/collector/multi_database_test.go @@ -0,0 +1,57 @@ +package collector + +import ( + "database/sql" + "testing" + + sqlmock "github.com/DATA-DOG/go-sqlmock" + "github.com/stretchr/testify/require" +) + +// TestConnectToDatabaseReusesInitialConnection guards against a regression +// where connectToDatabase opens (and immediately closes) a redundant +// connection for the one database in a fan-out that's already the database +// initial points to. With the real sql.Open-based factory, a freshly opened +// *sql.DB is never pointer-equal to initial even for an identical DSN, so +// the "same database" case has to be detected up front, before the factory +// is ever called -- a bare `conn != initial` check after the fact can't +// catch it. +func TestConnectToDatabaseReusesInitialConnection(t *testing.T) { + initial, _, err := sqlmock.New() + require.NoError(t, err) + defer initial.Close() + + newDB, _, err := sqlmock.New() + require.NoError(t, err) + defer newDB.Close() + + t.Run("same database as the DSN: reuses initial, never calls factory", func(t *testing.T) { + factoryCalls := 0 + factory := func(dsn string) (*sql.DB, error) { + factoryCalls++ + return newDB, nil + } + + conn, closeFn, err := connectToDatabase("postgres://user:pass@localhost:5432/books_store", "books_store", factory, initial) + require.NoError(t, err) + require.Same(t, initial, conn) + require.Equal(t, 0, factoryCalls) + closeFn() // must not close initial + + require.NoError(t, initial.PingContext(t.Context())) // still usable + }) + + t.Run("different database: opens a new connection via factory", func(t *testing.T) { + factoryCalls := 0 + factory := func(dsn string) (*sql.DB, error) { + factoryCalls++ + return newDB, nil + } + + conn, closeFn, err := connectToDatabase("postgres://user:pass@localhost:5432/postgres", "books_store", factory, initial) + require.NoError(t, err) + require.Same(t, newDB, conn) + require.Equal(t, 1, factoryCalls) + closeFn() + }) +} diff --git a/internal/component/database_observability/postgres/collector/schema_details.go b/internal/component/database_observability/postgres/collector/schema_details.go index c5a5ae1df9b..c0f4e91f02d 100644 --- a/internal/component/database_observability/postgres/collector/schema_details.go +++ b/internal/component/database_observability/postgres/collector/schema_details.go @@ -415,29 +415,11 @@ func (c *SchemaDetails) Stop() { } func (c *SchemaDetails) getAllDatabases(ctx context.Context) ([]string, error) { - query := fmt.Sprintf(selectAllDatabases, buildExcludedDatabasesClause(c.excludeDatabases)) - rows, err := c.initialConnection.QueryContext(ctx, query) + databases, err := discoverDatabases(ctx, c.initialConnection, c.excludeDatabases) if err != nil { c.logger.Error("failed to discover databases", "err", err) - return nil, fmt.Errorf("failed to discover databases: %w", err) - } - defer rows.Close() - - var databases []string - for rows.Next() { - var datname string - if err := rows.Scan(&datname); err != nil { - c.logger.Error("failed to scan database name", "err", err) - continue - } - databases = append(databases, datname) - } - - if err := rows.Err(); err != nil { - c.logger.Error("error iterating database rows", "err", err) - return nil, fmt.Errorf("error iterating database rows: %w", err) + return nil, err } - return databases, nil } @@ -597,31 +579,19 @@ func (c *SchemaDetails) extractNames(ctx context.Context) error { } for _, dbName := range databases { - databaseDSN, err := replaceDatabaseNameInDSN(c.dbDSN, dbName) + conn, closeConn, err := connectToDatabase(c.dbDSN, dbName, c.dbConnectionFactory, c.initialConnection) if err != nil { - c.logger.Error("failed to create DSN for database", "datname", dbName, "err", err) - continue - } - - conn, err := c.dbConnectionFactory(databaseDSN) - if err != nil { - c.logger.Error("failed to create connection to database", "datname", dbName, "err", err) + c.logger.Error("failed to connect to database", "datname", dbName, "err", err) continue } if err := c.extractSchemas(ctx, dbName, conn); err != nil { c.logger.Error("failed to collect schema from database", "datname", dbName, "err", err) - if conn != c.initialConnection { - conn.Close() - } + closeConn() continue } - if conn != c.initialConnection { - if err := conn.Close(); err != nil { - c.logger.Warn("failed to close database connection", "datname", dbName, "err", err) - } - } + closeConn() } // Drop throttle entries for databases that getAllDatabases no longer diff --git a/internal/component/database_observability/postgres/collector/table_stats.go b/internal/component/database_observability/postgres/collector/table_stats.go new file mode 100644 index 00000000000..4b98b26fcec --- /dev/null +++ b/internal/component/database_observability/postgres/collector/table_stats.go @@ -0,0 +1,164 @@ +package collector + +import ( + "context" + "database/sql" + "log/slog" + + "github.com/prometheus/client_golang/prometheus" + "go.uber.org/atomic" +) + +// TableStatsCollector emits the minimal set of table-level metrics needed for +// the missing-index KG insight, from pg_stat_user_tables, scoped to every +// database the connection can reach rather than only the one named in the DSN. +const TableStatsCollector = "table_stats" + +const selectTableScanStats = ` + SELECT + schemaname, + relname, + seq_scan, + idx_scan, + n_live_tup + FROM pg_stat_user_tables` + +const labelDatname = "datname" + +var tableLabels = []string{labelDatname, "schemaname", "relname"} + +var ( + tableScanStatsSeqScanDesc = prometheus.NewDesc( + prometheus.BuildFQName("pg", "stat_user_tables", "seq_scan"), + "Number of sequential scans initiated on this table", + tableLabels, nil, + ) + tableScanStatsIdxScanDesc = prometheus.NewDesc( + prometheus.BuildFQName("pg", "stat_user_tables", "idx_scan"), + "Number of index scans initiated on this table", + tableLabels, nil, + ) + tableScanStatsNLiveTupDesc = prometheus.NewDesc( + prometheus.BuildFQName("pg", "stat_user_tables", "n_live_tup"), + "Estimated number of live rows", + tableLabels, nil, + ) +) + +type TableStatsArguments struct { + DB *sql.DB + DSN string + ExcludeDatabases []string + Registry *prometheus.Registry + + Logger *slog.Logger + + dbConnectionFactory databaseConnectionFactory +} + +type TableStats struct { + initialConnection *sql.DB + dbDSN string + dbConnectionFactory databaseConnectionFactory + excludeDatabases []string + registry *prometheus.Registry + + logger *slog.Logger + running *atomic.Bool +} + +func NewTableStats(args TableStatsArguments) (*TableStats, error) { + factory := args.dbConnectionFactory + if factory == nil { + factory = defaultDbConnectionFactory + } + + return &TableStats{ + initialConnection: args.DB, + dbDSN: args.DSN, + dbConnectionFactory: factory, + excludeDatabases: args.ExcludeDatabases, + registry: args.Registry, + logger: args.Logger.With("collector", TableStatsCollector), + running: &atomic.Bool{}, + }, nil +} + +func (c *TableStats) Name() string { + return TableStatsCollector +} + +func (c *TableStats) Start(_ context.Context) error { + if err := c.registry.Register(c); err != nil { + return err + } + c.running.Store(true) + return nil +} + +func (c *TableStats) Stopped() bool { + return !c.running.Load() +} + +func (c *TableStats) Stop() { + c.registry.Unregister(c) + c.running.Store(false) +} + +// Describe implements prometheus.Collector. +func (c *TableStats) Describe(ch chan<- *prometheus.Desc) { + ch <- tableScanStatsSeqScanDesc + ch <- tableScanStatsIdxScanDesc + ch <- tableScanStatsNLiveTupDesc +} + +// Collect implements prometheus.Collector. It runs synchronously at scrape +// time, fanning out to every database the connection can reach. +func (c *TableStats) Collect(ch chan<- prometheus.Metric) { + ctx := context.Background() + + databases, err := discoverDatabases(ctx, c.initialConnection, c.excludeDatabases) + if err != nil { + c.logger.Error("failed to discover databases", "err", err) + return + } + + for _, dbName := range databases { + conn, closeConn, err := connectToDatabase(c.dbDSN, dbName, c.dbConnectionFactory, c.initialConnection) + if err != nil { + c.logger.Error("failed to connect to database", "datname", dbName, "err", err) + continue + } + + c.collectTableScanStats(ctx, dbName, conn, ch) + + closeConn() + } +} + +func (c *TableStats) collectTableScanStats(ctx context.Context, dbName string, conn *sql.DB, ch chan<- prometheus.Metric) { + rows, err := conn.QueryContext(ctx, selectTableScanStats) + if err != nil { + c.logger.Error("failed to query pg_stat_user_tables", "datname", dbName, "err", err) + return + } + defer rows.Close() + + for rows.Next() { + var schemaname, relname string + var seqScan, idxScan, nLiveTup sql.NullInt64 + + if err := rows.Scan(&schemaname, &relname, &seqScan, &idxScan, &nLiveTup); err != nil { + c.logger.Error("failed to scan pg_stat_user_tables row", "datname", dbName, "err", err) + return + } + + ch <- prometheus.MustNewConstMetric(tableScanStatsSeqScanDesc, prometheus.CounterValue, float64(seqScan.Int64), dbName, schemaname, relname) + ch <- prometheus.MustNewConstMetric(tableScanStatsIdxScanDesc, prometheus.CounterValue, float64(idxScan.Int64), dbName, schemaname, relname) + ch <- prometheus.MustNewConstMetric(tableScanStatsNLiveTupDesc, prometheus.GaugeValue, float64(nLiveTup.Int64), dbName, schemaname, relname) + } + + if err := rows.Err(); err != nil { + c.logger.Error("error iterating pg_stat_user_tables rows", "datname", dbName, "err", err) + } +} diff --git a/internal/component/database_observability/postgres/collector/table_stats_test.go b/internal/component/database_observability/postgres/collector/table_stats_test.go new file mode 100644 index 00000000000..2cc6f16b041 --- /dev/null +++ b/internal/component/database_observability/postgres/collector/table_stats_test.go @@ -0,0 +1,62 @@ +package collector + +import ( + "database/sql" + "fmt" + "strings" + "testing" + + sqlmock "github.com/DATA-DOG/go-sqlmock" + "github.com/prometheus/client_golang/prometheus" + "github.com/prometheus/client_golang/prometheus/testutil" + "github.com/stretchr/testify/require" + + "github.com/grafana/alloy/internal/util" +) + +func TestTableStats(t *testing.T) { + db, mock, err := sqlmock.New(sqlmock.QueryMatcherOption(sqlmock.QueryMatcherEqual)) + require.NoError(t, err) + defer db.Close() + + registry := prometheus.NewRegistry() + + c, err := NewTableStats(TableStatsArguments{ + DB: db, + DSN: "postgres://user:pass@localhost:5432/books_store", + ExcludeDatabases: nil, + Registry: registry, + Logger: util.TestAlloyLogger(t).Slog(), + dbConnectionFactory: func(dsn string) (*sql.DB, error) { + return db, nil + }, + }) + require.NoError(t, err) + + require.NoError(t, c.Start(t.Context())) + defer c.Stop() + + mock.ExpectQuery(fmt.Sprintf(selectAllDatabases, exclusionClause)).WithoutArgs().RowsWillBeClosed(). + WillReturnRows(sqlmock.NewRows([]string{"datname"}).AddRow("books_store")) + + mock.ExpectQuery(selectTableScanStats).WithoutArgs().RowsWillBeClosed(). + WillReturnRows( + sqlmock.NewRows([]string{"schemaname", "relname", "seq_scan", "idx_scan", "n_live_tup"}). + AddRow("public", "gen_adjectives", 37154, 0, 500), + ) + + expected := ` + # HELP pg_stat_user_tables_idx_scan Number of index scans initiated on this table + # TYPE pg_stat_user_tables_idx_scan counter + pg_stat_user_tables_idx_scan{datname="books_store",relname="gen_adjectives",schemaname="public"} 0 + # HELP pg_stat_user_tables_n_live_tup Estimated number of live rows + # TYPE pg_stat_user_tables_n_live_tup gauge + pg_stat_user_tables_n_live_tup{datname="books_store",relname="gen_adjectives",schemaname="public"} 500 + # HELP pg_stat_user_tables_seq_scan Number of sequential scans initiated on this table + # TYPE pg_stat_user_tables_seq_scan counter + pg_stat_user_tables_seq_scan{datname="books_store",relname="gen_adjectives",schemaname="public"} 37154 +` + + require.NoError(t, testutil.CollectAndCompare(registry, strings.NewReader(expected))) + require.NoError(t, mock.ExpectationsWereMet()) +} diff --git a/internal/component/database_observability/postgres/component.go b/internal/component/database_observability/postgres/component.go index 1d45fb17860..997c81be9d2 100644 --- a/internal/component/database_observability/postgres/component.go +++ b/internal/component/database_observability/postgres/component.go @@ -520,6 +520,8 @@ func enableOrDisableCollectors(a Arguments) map[string]bool { collector.QuerySamplesCollector: true, collector.SchemaDetailsCollector: true, collector.ExplainPlanCollector: true, + collector.TableStatsCollector: false, + collector.IndexStatsCollector: false, } for _, disabled := range a.DisableCollectors { @@ -671,6 +673,42 @@ func (c *Component) startCollectors(systemID string, engineVersion string, cloud c.instance.collectors = append(c.instance.collectors, epCollector) } + if collectors[collector.TableStatsCollector] { + tsCollector, err := collector.NewTableStats(collector.TableStatsArguments{ + DB: c.instance.dbConnection, + DSN: string(c.args.DataSourceName), + ExcludeDatabases: c.args.ExcludeDatabases, + Registry: c.instance.registry, + Logger: c.opts.Logger, + }) + if err != nil { + logStartError(collector.TableStatsCollector, "create", err) + } else { + if err := tsCollector.Start(context.Background()); err != nil { + logStartError(collector.TableStatsCollector, "start", err) + } + c.instance.collectors = append(c.instance.collectors, tsCollector) + } + } + + if collectors[collector.IndexStatsCollector] { + isCollector, err := collector.NewIndexStats(collector.IndexStatsArguments{ + DB: c.instance.dbConnection, + DSN: string(c.args.DataSourceName), + ExcludeDatabases: c.args.ExcludeDatabases, + Registry: c.instance.registry, + Logger: c.opts.Logger, + }) + if err != nil { + logStartError(collector.IndexStatsCollector, "create", err) + } else { + if err := isCollector.Start(context.Background()); err != nil { + logStartError(collector.IndexStatsCollector, "start", err) + } + c.instance.collectors = append(c.instance.collectors, isCollector) + } + } + // HealthCheck collector is always enabled hcCollector, err := collector.NewHealthCheck(collector.HealthCheckArguments{ DB: c.instance.dbConnection, diff --git a/internal/component/database_observability/postgres/component_test.go b/internal/component/database_observability/postgres/component_test.go index e736d8b5e7b..c818ff61251 100644 --- a/internal/component/database_observability/postgres/component_test.go +++ b/internal/component/database_observability/postgres/component_test.go @@ -117,6 +117,8 @@ func Test_enableOrDisableCollectors(t *testing.T) { collector.QuerySamplesCollector: true, collector.SchemaDetailsCollector: true, collector.ExplainPlanCollector: true, + collector.TableStatsCollector: false, + collector.IndexStatsCollector: false, }, actualCollectors) }) @@ -139,6 +141,8 @@ func Test_enableOrDisableCollectors(t *testing.T) { collector.QuerySamplesCollector: true, collector.SchemaDetailsCollector: true, collector.ExplainPlanCollector: true, + collector.TableStatsCollector: false, + collector.IndexStatsCollector: false, }, actualCollectors) }) @@ -161,6 +165,8 @@ func Test_enableOrDisableCollectors(t *testing.T) { collector.QuerySamplesCollector: true, collector.SchemaDetailsCollector: true, collector.ExplainPlanCollector: true, + collector.TableStatsCollector: false, + collector.IndexStatsCollector: false, }, actualCollectors) }) @@ -184,6 +190,8 @@ func Test_enableOrDisableCollectors(t *testing.T) { collector.QuerySamplesCollector: true, collector.SchemaDetailsCollector: true, collector.ExplainPlanCollector: true, + collector.TableStatsCollector: false, + collector.IndexStatsCollector: false, }, actualCollectors) }) @@ -207,6 +215,8 @@ func Test_enableOrDisableCollectors(t *testing.T) { collector.QuerySamplesCollector: true, collector.SchemaDetailsCollector: true, collector.ExplainPlanCollector: true, + collector.TableStatsCollector: false, + collector.IndexStatsCollector: false, }, actualCollectors) }) @@ -229,6 +239,8 @@ func Test_enableOrDisableCollectors(t *testing.T) { collector.QuerySamplesCollector: true, collector.SchemaDetailsCollector: true, collector.ExplainPlanCollector: true, + collector.TableStatsCollector: false, + collector.IndexStatsCollector: false, }, actualCollectors) }) @@ -251,6 +263,8 @@ func Test_enableOrDisableCollectors(t *testing.T) { collector.QuerySamplesCollector: true, collector.SchemaDetailsCollector: true, collector.ExplainPlanCollector: true, + collector.TableStatsCollector: false, + collector.IndexStatsCollector: false, }, actualCollectors) }) @@ -273,6 +287,8 @@ func Test_enableOrDisableCollectors(t *testing.T) { collector.QuerySamplesCollector: true, collector.SchemaDetailsCollector: true, collector.ExplainPlanCollector: true, + collector.TableStatsCollector: false, + collector.IndexStatsCollector: false, }, actualCollectors) }) @@ -295,6 +311,8 @@ func Test_enableOrDisableCollectors(t *testing.T) { collector.QuerySamplesCollector: false, collector.SchemaDetailsCollector: true, collector.ExplainPlanCollector: true, + collector.TableStatsCollector: false, + collector.IndexStatsCollector: false, }, actualCollectors) }) } From c7b18d275060d3a3243f1bdb50dbe14c708f7be1 Mon Sep 17 00:00:00 2001 From: Gabriel Antunes Date: Thu, 10 Sep 2026 11:56:00 -0300 Subject: [PATCH 02/11] Make postgres table_stats/index_stats doc descriptions engine-agnostic Drop the source system view names (pg_stat_user_tables/pg_stat_user_indexes) from the collector table, matching the terse, purpose-focused style already used for the other rows in this table. Co-Authored-By: Claude Sonnet 5 --- .../database_observability/database_observability.postgres.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/sources/reference/components/database_observability/database_observability.postgres.md b/docs/sources/reference/components/database_observability/database_observability.postgres.md index 035f1b4ee02..5d818ce6893 100644 --- a/docs/sources/reference/components/database_observability/database_observability.postgres.md +++ b/docs/sources/reference/components/database_observability/database_observability.postgres.md @@ -56,8 +56,8 @@ The following collectors are configurable: | Name | Description | Enabled by default | |------------------|-----------------------------------------------------------------------|--------------------| | `explain_plans` | Collect query explain plans. | yes | -| `table_stats` | Collect table scan statistics from `pg_stat_user_tables`, across every database the connection can reach, for missing-index detection. | no | -| `index_stats` | Collect per-index usage statistics from `pg_stat_user_indexes`, across every database the connection can reach, for unused-index detection. | no | +| `table_stats` | Collect table-level scan statistics, across every database the connection can reach, for missing-index detection. | no | +| `index_stats` | Collect per-index usage statistics, across every database the connection can reach, for unused-index detection. | no | | `logs` | Process PostgreSQL logs and export error metrics. | yes | | `query_details` | Collect queries information. | yes | | `query_samples` | Collect query samples and wait events information. | yes | From 5eef63fbc2bf362bc8ade8e30fc8686cfe482196 Mon Sep 17 00:00:00 2001 From: Gabriel Antunes Date: Thu, 10 Sep 2026 11:58:25 -0300 Subject: [PATCH 03/11] Trim postgres table_stats/index_stats doc descriptions further Co-Authored-By: Claude Sonnet 5 --- .../database_observability/database_observability.postgres.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/sources/reference/components/database_observability/database_observability.postgres.md b/docs/sources/reference/components/database_observability/database_observability.postgres.md index 5d818ce6893..300f6d56737 100644 --- a/docs/sources/reference/components/database_observability/database_observability.postgres.md +++ b/docs/sources/reference/components/database_observability/database_observability.postgres.md @@ -56,8 +56,8 @@ The following collectors are configurable: | Name | Description | Enabled by default | |------------------|-----------------------------------------------------------------------|--------------------| | `explain_plans` | Collect query explain plans. | yes | -| `table_stats` | Collect table-level scan statistics, across every database the connection can reach, for missing-index detection. | no | -| `index_stats` | Collect per-index usage statistics, across every database the connection can reach, for unused-index detection. | no | +| `table_stats` | Collect table-level scan statistics. | no | +| `index_stats` | Collect per-index usage statistics. | no | | `logs` | Process PostgreSQL logs and export error metrics. | yes | | `query_details` | Collect queries information. | yes | | `query_samples` | Collect query samples and wait events information. | yes | From 8dcbb4c7dd6d49f9b1e36f66e7c4e1bbd86132c9 Mon Sep 17 00:00:00 2001 From: Gabriel Antunes Date: Thu, 10 Sep 2026 12:01:29 -0300 Subject: [PATCH 04/11] Trim overly verbose code comments Shorten doc comments in the new postgres collectors: drop "KG insight" task-framing and restate-the-code prose, keep the genuinely non-obvious rationale (multi-database scoping, the redundant-connection gotcha, upstream-name matching) but state it more directly. Co-Authored-By: Claude Sonnet 5 --- .../postgres/collector/index_stats.go | 11 ++++----- .../postgres/collector/multi_database.go | 24 ++++++++----------- .../postgres/collector/multi_database_test.go | 12 ++++------ .../postgres/collector/table_stats.go | 6 ++--- 4 files changed, 22 insertions(+), 31 deletions(-) diff --git a/internal/component/database_observability/postgres/collector/index_stats.go b/internal/component/database_observability/postgres/collector/index_stats.go index 2641c0bd29e..d01ced165b0 100644 --- a/internal/component/database_observability/postgres/collector/index_stats.go +++ b/internal/component/database_observability/postgres/collector/index_stats.go @@ -9,9 +9,9 @@ import ( "go.uber.org/atomic" ) -// IndexStatsCollector emits the minimal set of per-index metrics needed for -// the unused-index KG insight, from pg_stat_user_indexes, scoped to every -// database the connection can reach rather than only the one named in the DSN. +// IndexStatsCollector emits per-index usage counters from pg_stat_user_indexes, +// scoped to every database the connection can reach rather than only the one +// named in the DSN. const IndexStatsCollector = "index_stats" const selectIndexUsageStats = ` @@ -28,9 +28,8 @@ const selectIndexUsageStats = ` var indexLabels = []string{labelDatname, "schemaname", "relname", "indexrelname"} var ( - // Named to match the metrics proposed by the (currently unmerged) upstream - // prometheus-community/postgres_exporter#1071, so that adopting the real - // upstream collector later, if/when it lands, needs no rule changes. + // Matches the naming of the proposed (unmerged) upstream pg_stat_user_indexes + // collector, so adopting it later needs no downstream rule changes. indexUsageIdxScanTotalDesc = prometheus.NewDesc( prometheus.BuildFQName("pg", "stat_user_indexes", "idx_scan_total"), "Number of index scans initiated on this index", diff --git a/internal/component/database_observability/postgres/collector/multi_database.go b/internal/component/database_observability/postgres/collector/multi_database.go index cfe0025a58a..59e709f9e92 100644 --- a/internal/component/database_observability/postgres/collector/multi_database.go +++ b/internal/component/database_observability/postgres/collector/multi_database.go @@ -6,12 +6,10 @@ import ( "fmt" ) -// discoverDatabases lists the databases on the Postgres instance that the -// current connection is allowed to CONNECT to, via the pg_database catalog -// view (readable from any single connection). Callers use this to fan out -// per-database connections, since most stat views (e.g. pg_stat_user_tables, -// pg_stat_user_indexes) only ever report on the database a connection is -// actually established to. +// discoverDatabases lists databases the current connection can reach, via +// pg_database (readable from any single connection) -- used to fan out +// per-database connections, since most stat views only report on the +// database a connection is actually established to. func discoverDatabases(ctx context.Context, conn *sql.DB, excludeDatabases []string) ([]string, error) { query := fmt.Sprintf(selectAllDatabases, buildExcludedDatabasesClause(excludeDatabases)) rows, err := conn.QueryContext(ctx, query) @@ -36,14 +34,12 @@ func discoverDatabases(ctx context.Context, conn *sql.DB, excludeDatabases []str return databases, nil } -// connectToDatabase opens a connection to dbName by rewriting the database -// name in dsn, using factory. If dbName is the database dsn (and so initial) -// already points to, initial is reused directly instead of opening a -// redundant connection: with the real sql.Open-based factory, a freshly -// opened *sql.DB is never pointer-equal to initial even for an identical -// DSN, so skipping the redundant open/close has to happen here, up front. -// The returned closeFn closes the connection unless it is initial (in which -// case closing it is the caller's responsibility elsewhere). +// connectToDatabase opens a connection to dbName by rewriting dsn. If dbName +// is already what dsn (and so initial) points to, it reuses initial instead +// of opening a redundant connection -- sql.Open never returns something +// pointer-equal to an existing *sql.DB, so this has to be checked by name up +// front, not via "conn != initial" after the fact. closeFn closes the +// connection unless it's initial. func connectToDatabase(dsn, dbName string, factory databaseConnectionFactory, initial *sql.DB) (conn *sql.DB, closeFn func(), err error) { noopClose := func() {} diff --git a/internal/component/database_observability/postgres/collector/multi_database_test.go b/internal/component/database_observability/postgres/collector/multi_database_test.go index 629bb0cb54a..96681c85968 100644 --- a/internal/component/database_observability/postgres/collector/multi_database_test.go +++ b/internal/component/database_observability/postgres/collector/multi_database_test.go @@ -8,14 +8,10 @@ import ( "github.com/stretchr/testify/require" ) -// TestConnectToDatabaseReusesInitialConnection guards against a regression -// where connectToDatabase opens (and immediately closes) a redundant -// connection for the one database in a fan-out that's already the database -// initial points to. With the real sql.Open-based factory, a freshly opened -// *sql.DB is never pointer-equal to initial even for an identical DSN, so -// the "same database" case has to be detected up front, before the factory -// is ever called -- a bare `conn != initial` check after the fact can't -// catch it. +// TestConnectToDatabaseReusesInitialConnection guards against connectToDatabase +// opening a redundant connection for the database initial already points to; +// see the comment on connectToDatabase for why a bare "conn != initial" check +// can't catch this. func TestConnectToDatabaseReusesInitialConnection(t *testing.T) { initial, _, err := sqlmock.New() require.NoError(t, err) diff --git a/internal/component/database_observability/postgres/collector/table_stats.go b/internal/component/database_observability/postgres/collector/table_stats.go index 4b98b26fcec..8a04c9083bc 100644 --- a/internal/component/database_observability/postgres/collector/table_stats.go +++ b/internal/component/database_observability/postgres/collector/table_stats.go @@ -9,9 +9,9 @@ import ( "go.uber.org/atomic" ) -// TableStatsCollector emits the minimal set of table-level metrics needed for -// the missing-index KG insight, from pg_stat_user_tables, scoped to every -// database the connection can reach rather than only the one named in the DSN. +// TableStatsCollector emits table-level scan counters from pg_stat_user_tables, +// scoped to every database the connection can reach rather than only the one +// named in the DSN. const TableStatsCollector = "table_stats" const selectTableScanStats = ` From 62602e9b04a30fe80c26e655e0a530a10e1d04b9 Mon Sep 17 00:00:00 2001 From: Gabriel Antunes Date: Thu, 10 Sep 2026 12:59:10 -0300 Subject: [PATCH 05/11] Namespace table_stats/index_stats metrics under database_observability Renames pg_stat_user_tables_*, pg_stat_user_indexes_idx_scan_total, pg_index_properties, and pg_index_size_bytes to database_observability_pg_stat_user_tables_*, database_observability_pg_stat_user_indexes_idx_scan_total, database_observability_pg_index_properties, and database_observability_pg_index_size_bytes -- matching the namespace ConnectionInfo and the logs collector's error metrics already use. This also closes a latent registry collision: table_stats' prior names were identical to the real embedded postgres_exporter's own stat_user_tables collector output, so enabling both table_stats and the prometheus_exporter block for the same target would have hit the same "duplicate metrics collector registration" conflict fixed for mysql's table_stats/index_stats a few commits back. Co-Authored-By: Claude Sonnet 5 --- .../postgres/collector/index_stats.go | 11 +++++---- .../postgres/collector/index_stats_test.go | 24 +++++++++---------- .../postgres/collector/table_stats.go | 6 ++--- .../postgres/collector/table_stats_test.go | 18 +++++++------- 4 files changed, 30 insertions(+), 29 deletions(-) diff --git a/internal/component/database_observability/postgres/collector/index_stats.go b/internal/component/database_observability/postgres/collector/index_stats.go index d01ced165b0..59d91a93e8b 100644 --- a/internal/component/database_observability/postgres/collector/index_stats.go +++ b/internal/component/database_observability/postgres/collector/index_stats.go @@ -28,20 +28,21 @@ const selectIndexUsageStats = ` var indexLabels = []string{labelDatname, "schemaname", "relname", "indexrelname"} var ( - // Matches the naming of the proposed (unmerged) upstream pg_stat_user_indexes - // collector, so adopting it later needs no downstream rule changes. + // Field names match the proposed (unmerged) upstream pg_stat_user_indexes + // collector; the database_observability namespace keeps them from + // colliding with that collector's own names if it ever ships. indexUsageIdxScanTotalDesc = prometheus.NewDesc( - prometheus.BuildFQName("pg", "stat_user_indexes", "idx_scan_total"), + prometheus.BuildFQName("database_observability", "pg_stat_user_indexes", "idx_scan_total"), "Number of index scans initiated on this index", indexLabels, nil, ) indexPropertiesDesc = prometheus.NewDesc( - "pg_index_properties", + prometheus.BuildFQName("database_observability", "pg", "index_properties"), "Properties of an index; a constant 1 with is_primary set to whether the index backs a primary key", append(append([]string{}, indexLabels...), "is_primary"), nil, ) indexSizeBytesDesc = prometheus.NewDesc( - "pg_index_size_bytes", + prometheus.BuildFQName("database_observability", "pg", "index_size_bytes"), "Total disk space used by this index, in bytes", indexLabels, nil, ) diff --git a/internal/component/database_observability/postgres/collector/index_stats_test.go b/internal/component/database_observability/postgres/collector/index_stats_test.go index 29c21cba6bf..c7237050581 100644 --- a/internal/component/database_observability/postgres/collector/index_stats_test.go +++ b/internal/component/database_observability/postgres/collector/index_stats_test.go @@ -47,18 +47,18 @@ func TestIndexStats(t *testing.T) { ) expected := ` - # HELP pg_stat_user_indexes_idx_scan_total Number of index scans initiated on this index - # TYPE pg_stat_user_indexes_idx_scan_total counter - pg_stat_user_indexes_idx_scan_total{datname="books_store",indexrelname="books_pkey",relname="books",schemaname="public"} 1.84e+08 - pg_stat_user_indexes_idx_scan_total{datname="books_store",indexrelname="idx_books_title",relname="books",schemaname="public"} 0 - # HELP pg_index_properties Properties of an index; a constant 1 with is_primary set to whether the index backs a primary key - # TYPE pg_index_properties gauge - pg_index_properties{datname="books_store",indexrelname="books_pkey",is_primary="true",relname="books",schemaname="public"} 1 - pg_index_properties{datname="books_store",indexrelname="idx_books_title",is_primary="false",relname="books",schemaname="public"} 1 - # HELP pg_index_size_bytes Total disk space used by this index, in bytes - # TYPE pg_index_size_bytes gauge - pg_index_size_bytes{datname="books_store",indexrelname="books_pkey",relname="books",schemaname="public"} 65536 - pg_index_size_bytes{datname="books_store",indexrelname="idx_books_title",relname="books",schemaname="public"} 32768 + # HELP database_observability_pg_stat_user_indexes_idx_scan_total Number of index scans initiated on this index + # TYPE database_observability_pg_stat_user_indexes_idx_scan_total counter + database_observability_pg_stat_user_indexes_idx_scan_total{datname="books_store",indexrelname="books_pkey",relname="books",schemaname="public"} 1.84e+08 + database_observability_pg_stat_user_indexes_idx_scan_total{datname="books_store",indexrelname="idx_books_title",relname="books",schemaname="public"} 0 + # HELP database_observability_pg_index_properties Properties of an index; a constant 1 with is_primary set to whether the index backs a primary key + # TYPE database_observability_pg_index_properties gauge + database_observability_pg_index_properties{datname="books_store",indexrelname="books_pkey",is_primary="true",relname="books",schemaname="public"} 1 + database_observability_pg_index_properties{datname="books_store",indexrelname="idx_books_title",is_primary="false",relname="books",schemaname="public"} 1 + # HELP database_observability_pg_index_size_bytes Total disk space used by this index, in bytes + # TYPE database_observability_pg_index_size_bytes gauge + database_observability_pg_index_size_bytes{datname="books_store",indexrelname="books_pkey",relname="books",schemaname="public"} 65536 + database_observability_pg_index_size_bytes{datname="books_store",indexrelname="idx_books_title",relname="books",schemaname="public"} 32768 ` require.NoError(t, testutil.CollectAndCompare(registry, strings.NewReader(expected))) diff --git a/internal/component/database_observability/postgres/collector/table_stats.go b/internal/component/database_observability/postgres/collector/table_stats.go index 8a04c9083bc..320721a502c 100644 --- a/internal/component/database_observability/postgres/collector/table_stats.go +++ b/internal/component/database_observability/postgres/collector/table_stats.go @@ -29,17 +29,17 @@ var tableLabels = []string{labelDatname, "schemaname", "relname"} var ( tableScanStatsSeqScanDesc = prometheus.NewDesc( - prometheus.BuildFQName("pg", "stat_user_tables", "seq_scan"), + prometheus.BuildFQName("database_observability", "pg_stat_user_tables", "seq_scan"), "Number of sequential scans initiated on this table", tableLabels, nil, ) tableScanStatsIdxScanDesc = prometheus.NewDesc( - prometheus.BuildFQName("pg", "stat_user_tables", "idx_scan"), + prometheus.BuildFQName("database_observability", "pg_stat_user_tables", "idx_scan"), "Number of index scans initiated on this table", tableLabels, nil, ) tableScanStatsNLiveTupDesc = prometheus.NewDesc( - prometheus.BuildFQName("pg", "stat_user_tables", "n_live_tup"), + prometheus.BuildFQName("database_observability", "pg_stat_user_tables", "n_live_tup"), "Estimated number of live rows", tableLabels, nil, ) diff --git a/internal/component/database_observability/postgres/collector/table_stats_test.go b/internal/component/database_observability/postgres/collector/table_stats_test.go index 2cc6f16b041..aa477dfa48d 100644 --- a/internal/component/database_observability/postgres/collector/table_stats_test.go +++ b/internal/component/database_observability/postgres/collector/table_stats_test.go @@ -46,15 +46,15 @@ func TestTableStats(t *testing.T) { ) expected := ` - # HELP pg_stat_user_tables_idx_scan Number of index scans initiated on this table - # TYPE pg_stat_user_tables_idx_scan counter - pg_stat_user_tables_idx_scan{datname="books_store",relname="gen_adjectives",schemaname="public"} 0 - # HELP pg_stat_user_tables_n_live_tup Estimated number of live rows - # TYPE pg_stat_user_tables_n_live_tup gauge - pg_stat_user_tables_n_live_tup{datname="books_store",relname="gen_adjectives",schemaname="public"} 500 - # HELP pg_stat_user_tables_seq_scan Number of sequential scans initiated on this table - # TYPE pg_stat_user_tables_seq_scan counter - pg_stat_user_tables_seq_scan{datname="books_store",relname="gen_adjectives",schemaname="public"} 37154 + # HELP database_observability_pg_stat_user_tables_idx_scan Number of index scans initiated on this table + # TYPE database_observability_pg_stat_user_tables_idx_scan counter + database_observability_pg_stat_user_tables_idx_scan{datname="books_store",relname="gen_adjectives",schemaname="public"} 0 + # HELP database_observability_pg_stat_user_tables_n_live_tup Estimated number of live rows + # TYPE database_observability_pg_stat_user_tables_n_live_tup gauge + database_observability_pg_stat_user_tables_n_live_tup{datname="books_store",relname="gen_adjectives",schemaname="public"} 500 + # HELP database_observability_pg_stat_user_tables_seq_scan Number of sequential scans initiated on this table + # TYPE database_observability_pg_stat_user_tables_seq_scan counter + database_observability_pg_stat_user_tables_seq_scan{datname="books_store",relname="gen_adjectives",schemaname="public"} 37154 ` require.NoError(t, testutil.CollectAndCompare(registry, strings.NewReader(expected))) From 623ececa33f478dd26b317f741bd231f4ba4ce77 Mon Sep 17 00:00:00 2001 From: Gabriel Antunes Date: Fri, 11 Sep 2026 13:34:12 -0300 Subject: [PATCH 06/11] Address review feedback: merge multi_database.go into dsn.go, drop index_properties Per Cristian's review on the postgres PR: - Move discoverDatabases/connectToDatabase and selectAllDatabases into dsn.go, since DSN parsing and per-database connection fan-out go together; drop the now-empty multi_database.go/_test.go. - Drop the pg_index_properties metric and its is_primary column/join: schema_details already surfaces primary-key info, and index_stats has no equivalent in the mysql PR. This also removes the double-append needed only to build that metric's label list. - Trim the now-stale "field names match upstream" comment on index_stats. Co-Authored-By: Claude Sonnet 5 --- .../postgres/collector/dsn.go | 70 +++++++++++++++++++ .../postgres/collector/dsn_test.go | 46 ++++++++++++ .../postgres/collector/index_stats.go | 22 +----- .../postgres/collector/index_stats_test.go | 10 +-- .../postgres/collector/multi_database.go | 67 ------------------ .../postgres/collector/multi_database_test.go | 53 -------------- .../postgres/collector/schema_details.go | 8 --- 7 files changed, 121 insertions(+), 155 deletions(-) delete mode 100644 internal/component/database_observability/postgres/collector/multi_database.go delete mode 100644 internal/component/database_observability/postgres/collector/multi_database_test.go diff --git a/internal/component/database_observability/postgres/collector/dsn.go b/internal/component/database_observability/postgres/collector/dsn.go index 82347f1c27e..e689aa45e9a 100644 --- a/internal/component/database_observability/postgres/collector/dsn.go +++ b/internal/component/database_observability/postgres/collector/dsn.go @@ -1,8 +1,10 @@ package collector import ( + "context" "database/sql" "errors" + "fmt" "regexp" ) @@ -13,6 +15,14 @@ var defaultDbConnectionFactory = func(dsn string) (*sql.DB, error) { return sql.Open("postgres", dsn) } +// selectAllDatabases makes use of the initial DB connection to discover other databases on the same Postgres instance +const selectAllDatabases = ` + SELECT datname + FROM pg_database + WHERE datistemplate = false + AND has_database_privilege(datname, 'CONNECT') + AND datname NOT IN %s` + // replaceDatabaseNameInDSN safely replaces the database name in a PostgreSQL DSN // using regex to ensure only the database name portion is replaced, not other occurrences func replaceDatabaseNameInDSN(dsn, newDatabaseName string) (string, error) { @@ -41,3 +51,63 @@ func databaseNameFromDSN(dsn string) (string, error) { } return matches[2], nil } + +// discoverDatabases lists databases the current connection can reach, via +// pg_database (readable from any single connection) -- used to fan out +// per-database connections, since most stat views only report on the +// database a connection is actually established to. +func discoverDatabases(ctx context.Context, conn *sql.DB, excludeDatabases []string) ([]string, error) { + query := fmt.Sprintf(selectAllDatabases, buildExcludedDatabasesClause(excludeDatabases)) + rows, err := conn.QueryContext(ctx, query) + if err != nil { + return nil, fmt.Errorf("failed to discover databases: %w", err) + } + defer rows.Close() + + var databases []string + for rows.Next() { + var datname string + if err := rows.Scan(&datname); err != nil { + return nil, fmt.Errorf("failed to scan database name: %w", err) + } + databases = append(databases, datname) + } + + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("error iterating database rows: %w", err) + } + + return databases, nil +} + +// connectToDatabase opens a connection to dbName by rewriting dsn. If dbName +// is already what dsn (and so initial) points to, it reuses initial instead +// of opening a redundant connection -- sql.Open never returns something +// pointer-equal to an existing *sql.DB, so this has to be checked by name up +// front, not via "conn != initial" after the fact. closeFn closes the +// connection unless it's initial. +func connectToDatabase(dsn, dbName string, factory databaseConnectionFactory, initial *sql.DB) (conn *sql.DB, closeFn func(), err error) { + noopClose := func() {} + + if currentDBName, err := databaseNameFromDSN(dsn); err == nil && currentDBName == dbName { + return initial, noopClose, nil + } + + databaseDSN, err := replaceDatabaseNameInDSN(dsn, dbName) + if err != nil { + return nil, nil, fmt.Errorf("failed to create DSN for database %s: %w", dbName, err) + } + + conn, err = factory(databaseDSN) + if err != nil { + return nil, nil, fmt.Errorf("failed to create connection to database %s: %w", dbName, err) + } + + closeFn = func() { + if conn != initial { + conn.Close() + } + } + + return conn, closeFn, nil +} diff --git a/internal/component/database_observability/postgres/collector/dsn_test.go b/internal/component/database_observability/postgres/collector/dsn_test.go index b65c7586fad..2bcdf752a24 100644 --- a/internal/component/database_observability/postgres/collector/dsn_test.go +++ b/internal/component/database_observability/postgres/collector/dsn_test.go @@ -1,8 +1,10 @@ package collector import ( + "database/sql" "testing" + sqlmock "github.com/DATA-DOG/go-sqlmock" "github.com/stretchr/testify/require" ) @@ -96,3 +98,47 @@ func TestReplaceDatabaseNameInDSN(t *testing.T) { }) } } + +// TestConnectToDatabaseReusesInitialConnection guards against connectToDatabase +// opening a redundant connection for the database initial already points to; +// see the comment on connectToDatabase for why a bare "conn != initial" check +// can't catch this. +func TestConnectToDatabaseReusesInitialConnection(t *testing.T) { + initial, _, err := sqlmock.New() + require.NoError(t, err) + defer initial.Close() + + newDB, _, err := sqlmock.New() + require.NoError(t, err) + defer newDB.Close() + + t.Run("same database as the DSN: reuses initial, never calls factory", func(t *testing.T) { + factoryCalls := 0 + factory := func(dsn string) (*sql.DB, error) { + factoryCalls++ + return newDB, nil + } + + conn, closeFn, err := connectToDatabase("postgres://user:pass@localhost:5432/books_store", "books_store", factory, initial) + require.NoError(t, err) + require.Same(t, initial, conn) + require.Equal(t, 0, factoryCalls) + closeFn() // must not close initial + + require.NoError(t, initial.PingContext(t.Context())) // still usable + }) + + t.Run("different database: opens a new connection via factory", func(t *testing.T) { + factoryCalls := 0 + factory := func(dsn string) (*sql.DB, error) { + factoryCalls++ + return newDB, nil + } + + conn, closeFn, err := connectToDatabase("postgres://user:pass@localhost:5432/postgres", "books_store", factory, initial) + require.NoError(t, err) + require.Same(t, newDB, conn) + require.Equal(t, 1, factoryCalls) + closeFn() + }) +} diff --git a/internal/component/database_observability/postgres/collector/index_stats.go b/internal/component/database_observability/postgres/collector/index_stats.go index 59d91a93e8b..af87e98c684 100644 --- a/internal/component/database_observability/postgres/collector/index_stats.go +++ b/internal/component/database_observability/postgres/collector/index_stats.go @@ -20,27 +20,17 @@ const selectIndexUsageStats = ` s.relname, s.indexrelname, s.idx_scan, - i.indisprimary, pg_relation_size(s.indexrelid) AS index_size_bytes - FROM pg_stat_user_indexes s - JOIN pg_index i ON i.indexrelid = s.indexrelid` + FROM pg_stat_user_indexes s` var indexLabels = []string{labelDatname, "schemaname", "relname", "indexrelname"} var ( - // Field names match the proposed (unmerged) upstream pg_stat_user_indexes - // collector; the database_observability namespace keeps them from - // colliding with that collector's own names if it ever ships. indexUsageIdxScanTotalDesc = prometheus.NewDesc( prometheus.BuildFQName("database_observability", "pg_stat_user_indexes", "idx_scan_total"), "Number of index scans initiated on this index", indexLabels, nil, ) - indexPropertiesDesc = prometheus.NewDesc( - prometheus.BuildFQName("database_observability", "pg", "index_properties"), - "Properties of an index; a constant 1 with is_primary set to whether the index backs a primary key", - append(append([]string{}, indexLabels...), "is_primary"), nil, - ) indexSizeBytesDesc = prometheus.NewDesc( prometheus.BuildFQName("database_observability", "pg", "index_size_bytes"), "Total disk space used by this index, in bytes", @@ -111,7 +101,6 @@ func (c *IndexStats) Stop() { // Describe implements prometheus.Collector. func (c *IndexStats) Describe(ch chan<- *prometheus.Desc) { ch <- indexUsageIdxScanTotalDesc - ch <- indexPropertiesDesc ch <- indexSizeBytesDesc } @@ -150,20 +139,13 @@ func (c *IndexStats) collectIndexUsageStats(ctx context.Context, dbName string, for rows.Next() { var schemaname, relname, indexrelname string var idxScan, indexSizeBytes sql.NullInt64 - var isPrimary bool - if err := rows.Scan(&schemaname, &relname, &indexrelname, &idxScan, &isPrimary, &indexSizeBytes); err != nil { + if err := rows.Scan(&schemaname, &relname, &indexrelname, &idxScan, &indexSizeBytes); err != nil { c.logger.Error("failed to scan pg_stat_user_indexes row", "datname", dbName, "err", err) return } - isPrimaryLabel := "false" - if isPrimary { - isPrimaryLabel = "true" - } - ch <- prometheus.MustNewConstMetric(indexUsageIdxScanTotalDesc, prometheus.CounterValue, float64(idxScan.Int64), dbName, schemaname, relname, indexrelname) - ch <- prometheus.MustNewConstMetric(indexPropertiesDesc, prometheus.GaugeValue, 1, dbName, schemaname, relname, indexrelname, isPrimaryLabel) ch <- prometheus.MustNewConstMetric(indexSizeBytesDesc, prometheus.GaugeValue, float64(indexSizeBytes.Int64), dbName, schemaname, relname, indexrelname) } diff --git a/internal/component/database_observability/postgres/collector/index_stats_test.go b/internal/component/database_observability/postgres/collector/index_stats_test.go index c7237050581..3f20cc4f242 100644 --- a/internal/component/database_observability/postgres/collector/index_stats_test.go +++ b/internal/component/database_observability/postgres/collector/index_stats_test.go @@ -41,9 +41,9 @@ func TestIndexStats(t *testing.T) { mock.ExpectQuery(selectIndexUsageStats).WithoutArgs().RowsWillBeClosed(). WillReturnRows( - sqlmock.NewRows([]string{"schemaname", "relname", "indexrelname", "idx_scan", "indisprimary", "index_size_bytes"}). - AddRow("public", "books", "books_pkey", 184000000, true, 65536). - AddRow("public", "books", "idx_books_title", 0, false, 32768), + sqlmock.NewRows([]string{"schemaname", "relname", "indexrelname", "idx_scan", "index_size_bytes"}). + AddRow("public", "books", "books_pkey", 184000000, 65536). + AddRow("public", "books", "idx_books_title", 0, 32768), ) expected := ` @@ -51,10 +51,6 @@ func TestIndexStats(t *testing.T) { # TYPE database_observability_pg_stat_user_indexes_idx_scan_total counter database_observability_pg_stat_user_indexes_idx_scan_total{datname="books_store",indexrelname="books_pkey",relname="books",schemaname="public"} 1.84e+08 database_observability_pg_stat_user_indexes_idx_scan_total{datname="books_store",indexrelname="idx_books_title",relname="books",schemaname="public"} 0 - # HELP database_observability_pg_index_properties Properties of an index; a constant 1 with is_primary set to whether the index backs a primary key - # TYPE database_observability_pg_index_properties gauge - database_observability_pg_index_properties{datname="books_store",indexrelname="books_pkey",is_primary="true",relname="books",schemaname="public"} 1 - database_observability_pg_index_properties{datname="books_store",indexrelname="idx_books_title",is_primary="false",relname="books",schemaname="public"} 1 # HELP database_observability_pg_index_size_bytes Total disk space used by this index, in bytes # TYPE database_observability_pg_index_size_bytes gauge database_observability_pg_index_size_bytes{datname="books_store",indexrelname="books_pkey",relname="books",schemaname="public"} 65536 diff --git a/internal/component/database_observability/postgres/collector/multi_database.go b/internal/component/database_observability/postgres/collector/multi_database.go deleted file mode 100644 index 59e709f9e92..00000000000 --- a/internal/component/database_observability/postgres/collector/multi_database.go +++ /dev/null @@ -1,67 +0,0 @@ -package collector - -import ( - "context" - "database/sql" - "fmt" -) - -// discoverDatabases lists databases the current connection can reach, via -// pg_database (readable from any single connection) -- used to fan out -// per-database connections, since most stat views only report on the -// database a connection is actually established to. -func discoverDatabases(ctx context.Context, conn *sql.DB, excludeDatabases []string) ([]string, error) { - query := fmt.Sprintf(selectAllDatabases, buildExcludedDatabasesClause(excludeDatabases)) - rows, err := conn.QueryContext(ctx, query) - if err != nil { - return nil, fmt.Errorf("failed to discover databases: %w", err) - } - defer rows.Close() - - var databases []string - for rows.Next() { - var datname string - if err := rows.Scan(&datname); err != nil { - return nil, fmt.Errorf("failed to scan database name: %w", err) - } - databases = append(databases, datname) - } - - if err := rows.Err(); err != nil { - return nil, fmt.Errorf("error iterating database rows: %w", err) - } - - return databases, nil -} - -// connectToDatabase opens a connection to dbName by rewriting dsn. If dbName -// is already what dsn (and so initial) points to, it reuses initial instead -// of opening a redundant connection -- sql.Open never returns something -// pointer-equal to an existing *sql.DB, so this has to be checked by name up -// front, not via "conn != initial" after the fact. closeFn closes the -// connection unless it's initial. -func connectToDatabase(dsn, dbName string, factory databaseConnectionFactory, initial *sql.DB) (conn *sql.DB, closeFn func(), err error) { - noopClose := func() {} - - if currentDBName, err := databaseNameFromDSN(dsn); err == nil && currentDBName == dbName { - return initial, noopClose, nil - } - - databaseDSN, err := replaceDatabaseNameInDSN(dsn, dbName) - if err != nil { - return nil, nil, fmt.Errorf("failed to create DSN for database %s: %w", dbName, err) - } - - conn, err = factory(databaseDSN) - if err != nil { - return nil, nil, fmt.Errorf("failed to create connection to database %s: %w", dbName, err) - } - - closeFn = func() { - if conn != initial { - conn.Close() - } - } - - return conn, closeFn, nil -} diff --git a/internal/component/database_observability/postgres/collector/multi_database_test.go b/internal/component/database_observability/postgres/collector/multi_database_test.go deleted file mode 100644 index 96681c85968..00000000000 --- a/internal/component/database_observability/postgres/collector/multi_database_test.go +++ /dev/null @@ -1,53 +0,0 @@ -package collector - -import ( - "database/sql" - "testing" - - sqlmock "github.com/DATA-DOG/go-sqlmock" - "github.com/stretchr/testify/require" -) - -// TestConnectToDatabaseReusesInitialConnection guards against connectToDatabase -// opening a redundant connection for the database initial already points to; -// see the comment on connectToDatabase for why a bare "conn != initial" check -// can't catch this. -func TestConnectToDatabaseReusesInitialConnection(t *testing.T) { - initial, _, err := sqlmock.New() - require.NoError(t, err) - defer initial.Close() - - newDB, _, err := sqlmock.New() - require.NoError(t, err) - defer newDB.Close() - - t.Run("same database as the DSN: reuses initial, never calls factory", func(t *testing.T) { - factoryCalls := 0 - factory := func(dsn string) (*sql.DB, error) { - factoryCalls++ - return newDB, nil - } - - conn, closeFn, err := connectToDatabase("postgres://user:pass@localhost:5432/books_store", "books_store", factory, initial) - require.NoError(t, err) - require.Same(t, initial, conn) - require.Equal(t, 0, factoryCalls) - closeFn() // must not close initial - - require.NoError(t, initial.PingContext(t.Context())) // still usable - }) - - t.Run("different database: opens a new connection via factory", func(t *testing.T) { - factoryCalls := 0 - factory := func(dsn string) (*sql.DB, error) { - factoryCalls++ - return newDB, nil - } - - conn, closeFn, err := connectToDatabase("postgres://user:pass@localhost:5432/postgres", "books_store", factory, initial) - require.NoError(t, err) - require.Same(t, newDB, conn) - require.Equal(t, 1, factoryCalls) - closeFn() - }) -} diff --git a/internal/component/database_observability/postgres/collector/schema_details.go b/internal/component/database_observability/postgres/collector/schema_details.go index 1c087a49155..77dc8da5273 100644 --- a/internal/component/database_observability/postgres/collector/schema_details.go +++ b/internal/component/database_observability/postgres/collector/schema_details.go @@ -26,14 +26,6 @@ const ( ) const ( - // selectAllDatabases makes use of the initial DB connection to discover other databases on the same Postgres instance - selectAllDatabases = ` - SELECT datname - FROM pg_database - WHERE datistemplate = false - AND has_database_privilege(datname, 'CONNECT') - AND datname NOT IN %s` - // selectSchemaNames gets all user-defined schemas, excluding system schemas selectSchemaNames = ` SELECT From 018fd1fadc495c9f15cdf4553b786823d01c8829 Mon Sep 17 00:00:00 2001 From: Gabriel Antunes Date: Fri, 11 Sep 2026 14:08:19 -0300 Subject: [PATCH 07/11] Fold index_properties into index_size_bytes as labels Rather than a separate constant-1 index_properties metric, attach is_primary, is_unique, and is_partial directly as labels on index_size_bytes. These are 1:1 with an index the same way schemaname and indexrelname already are, so this adds no series -- a separate info-style metric would have cost a full extra series per index for re-emitting the same identifying labels. Co-Authored-By: Claude Sonnet 5 --- .../postgres/collector/index_stats.go | 19 ++++++++++++++----- .../postgres/collector/index_stats_test.go | 12 ++++++------ 2 files changed, 20 insertions(+), 11 deletions(-) diff --git a/internal/component/database_observability/postgres/collector/index_stats.go b/internal/component/database_observability/postgres/collector/index_stats.go index af87e98c684..3d70f9ad58e 100644 --- a/internal/component/database_observability/postgres/collector/index_stats.go +++ b/internal/component/database_observability/postgres/collector/index_stats.go @@ -4,6 +4,7 @@ import ( "context" "database/sql" "log/slog" + "strconv" "github.com/prometheus/client_golang/prometheus" "go.uber.org/atomic" @@ -20,10 +21,15 @@ const selectIndexUsageStats = ` s.relname, s.indexrelname, s.idx_scan, + i.indisprimary, + i.indisunique, + i.indpred IS NOT NULL AS is_partial, pg_relation_size(s.indexrelid) AS index_size_bytes - FROM pg_stat_user_indexes s` + FROM pg_stat_user_indexes s + JOIN pg_index i ON i.indexrelid = s.indexrelid` var indexLabels = []string{labelDatname, "schemaname", "relname", "indexrelname"} +var indexSizeLabels = append(append([]string{}, indexLabels...), "is_primary", "is_unique", "is_partial") var ( indexUsageIdxScanTotalDesc = prometheus.NewDesc( @@ -33,8 +39,8 @@ var ( ) indexSizeBytesDesc = prometheus.NewDesc( prometheus.BuildFQName("database_observability", "pg", "index_size_bytes"), - "Total disk space used by this index, in bytes", - indexLabels, nil, + "Total disk space used by this index, in bytes, labeled with whether it backs a primary key or unique constraint, or is partial", + indexSizeLabels, nil, ) ) @@ -139,14 +145,17 @@ func (c *IndexStats) collectIndexUsageStats(ctx context.Context, dbName string, for rows.Next() { var schemaname, relname, indexrelname string var idxScan, indexSizeBytes sql.NullInt64 + var isPrimary, isUnique, isPartial bool - if err := rows.Scan(&schemaname, &relname, &indexrelname, &idxScan, &indexSizeBytes); err != nil { + if err := rows.Scan(&schemaname, &relname, &indexrelname, &idxScan, &isPrimary, &isUnique, &isPartial, &indexSizeBytes); err != nil { c.logger.Error("failed to scan pg_stat_user_indexes row", "datname", dbName, "err", err) return } ch <- prometheus.MustNewConstMetric(indexUsageIdxScanTotalDesc, prometheus.CounterValue, float64(idxScan.Int64), dbName, schemaname, relname, indexrelname) - ch <- prometheus.MustNewConstMetric(indexSizeBytesDesc, prometheus.GaugeValue, float64(indexSizeBytes.Int64), dbName, schemaname, relname, indexrelname) + ch <- prometheus.MustNewConstMetric(indexSizeBytesDesc, prometheus.GaugeValue, float64(indexSizeBytes.Int64), + dbName, schemaname, relname, indexrelname, + strconv.FormatBool(isPrimary), strconv.FormatBool(isUnique), strconv.FormatBool(isPartial)) } if err := rows.Err(); err != nil { diff --git a/internal/component/database_observability/postgres/collector/index_stats_test.go b/internal/component/database_observability/postgres/collector/index_stats_test.go index 3f20cc4f242..df0fd8d6ccd 100644 --- a/internal/component/database_observability/postgres/collector/index_stats_test.go +++ b/internal/component/database_observability/postgres/collector/index_stats_test.go @@ -41,9 +41,9 @@ func TestIndexStats(t *testing.T) { mock.ExpectQuery(selectIndexUsageStats).WithoutArgs().RowsWillBeClosed(). WillReturnRows( - sqlmock.NewRows([]string{"schemaname", "relname", "indexrelname", "idx_scan", "index_size_bytes"}). - AddRow("public", "books", "books_pkey", 184000000, 65536). - AddRow("public", "books", "idx_books_title", 0, 32768), + sqlmock.NewRows([]string{"schemaname", "relname", "indexrelname", "idx_scan", "indisprimary", "indisunique", "is_partial", "index_size_bytes"}). + AddRow("public", "books", "books_pkey", 184000000, true, true, false, 65536). + AddRow("public", "books", "idx_books_title", 0, false, false, true, 32768), ) expected := ` @@ -51,10 +51,10 @@ func TestIndexStats(t *testing.T) { # TYPE database_observability_pg_stat_user_indexes_idx_scan_total counter database_observability_pg_stat_user_indexes_idx_scan_total{datname="books_store",indexrelname="books_pkey",relname="books",schemaname="public"} 1.84e+08 database_observability_pg_stat_user_indexes_idx_scan_total{datname="books_store",indexrelname="idx_books_title",relname="books",schemaname="public"} 0 - # HELP database_observability_pg_index_size_bytes Total disk space used by this index, in bytes + # HELP database_observability_pg_index_size_bytes Total disk space used by this index, in bytes, labeled with whether it backs a primary key or unique constraint, or is partial # TYPE database_observability_pg_index_size_bytes gauge - database_observability_pg_index_size_bytes{datname="books_store",indexrelname="books_pkey",relname="books",schemaname="public"} 65536 - database_observability_pg_index_size_bytes{datname="books_store",indexrelname="idx_books_title",relname="books",schemaname="public"} 32768 + database_observability_pg_index_size_bytes{datname="books_store",indexrelname="books_pkey",is_partial="false",is_primary="true",is_unique="true",relname="books",schemaname="public"} 65536 + database_observability_pg_index_size_bytes{datname="books_store",indexrelname="idx_books_title",is_partial="true",is_primary="false",is_unique="false",relname="books",schemaname="public"} 32768 ` require.NoError(t, testutil.CollectAndCompare(registry, strings.NewReader(expected))) From 0b603cf8b79cb5d9fa17fa81c8ef4ba4d7b91352 Mon Sep 17 00:00:00 2001 From: Gabriel Antunes Date: Fri, 11 Sep 2026 14:29:19 -0300 Subject: [PATCH 08/11] Rename pg metric groups to pg_index_stats/pg_table_stats Mirrors the mysql_index_stats/mysql_table_stats subsystem naming from the mysql PR: pg_stat_user_indexes/pg -> pg_index_stats (idx_scan_total, size_bytes), pg_stat_user_tables -> pg_table_stats (seq_scan, idx_scan, n_live_tup). SQL/log references to the actual pg_stat_user_indexes and pg_stat_user_tables views are unchanged -- only the metric namespace segment moves. Co-Authored-By: Claude Sonnet 5 --- .../postgres/collector/index_stats.go | 4 ++-- .../postgres/collector/index_stats_test.go | 16 ++++++++-------- .../postgres/collector/table_stats.go | 6 +++--- .../postgres/collector/table_stats_test.go | 18 +++++++++--------- 4 files changed, 22 insertions(+), 22 deletions(-) diff --git a/internal/component/database_observability/postgres/collector/index_stats.go b/internal/component/database_observability/postgres/collector/index_stats.go index 3d70f9ad58e..d73e09dc8ca 100644 --- a/internal/component/database_observability/postgres/collector/index_stats.go +++ b/internal/component/database_observability/postgres/collector/index_stats.go @@ -33,12 +33,12 @@ var indexSizeLabels = append(append([]string{}, indexLabels...), "is_primary", " var ( indexUsageIdxScanTotalDesc = prometheus.NewDesc( - prometheus.BuildFQName("database_observability", "pg_stat_user_indexes", "idx_scan_total"), + prometheus.BuildFQName("database_observability", "pg_index_stats", "idx_scan_total"), "Number of index scans initiated on this index", indexLabels, nil, ) indexSizeBytesDesc = prometheus.NewDesc( - prometheus.BuildFQName("database_observability", "pg", "index_size_bytes"), + prometheus.BuildFQName("database_observability", "pg_index_stats", "size_bytes"), "Total disk space used by this index, in bytes, labeled with whether it backs a primary key or unique constraint, or is partial", indexSizeLabels, nil, ) diff --git a/internal/component/database_observability/postgres/collector/index_stats_test.go b/internal/component/database_observability/postgres/collector/index_stats_test.go index df0fd8d6ccd..5957089d79c 100644 --- a/internal/component/database_observability/postgres/collector/index_stats_test.go +++ b/internal/component/database_observability/postgres/collector/index_stats_test.go @@ -47,14 +47,14 @@ func TestIndexStats(t *testing.T) { ) expected := ` - # HELP database_observability_pg_stat_user_indexes_idx_scan_total Number of index scans initiated on this index - # TYPE database_observability_pg_stat_user_indexes_idx_scan_total counter - database_observability_pg_stat_user_indexes_idx_scan_total{datname="books_store",indexrelname="books_pkey",relname="books",schemaname="public"} 1.84e+08 - database_observability_pg_stat_user_indexes_idx_scan_total{datname="books_store",indexrelname="idx_books_title",relname="books",schemaname="public"} 0 - # HELP database_observability_pg_index_size_bytes Total disk space used by this index, in bytes, labeled with whether it backs a primary key or unique constraint, or is partial - # TYPE database_observability_pg_index_size_bytes gauge - database_observability_pg_index_size_bytes{datname="books_store",indexrelname="books_pkey",is_partial="false",is_primary="true",is_unique="true",relname="books",schemaname="public"} 65536 - database_observability_pg_index_size_bytes{datname="books_store",indexrelname="idx_books_title",is_partial="true",is_primary="false",is_unique="false",relname="books",schemaname="public"} 32768 + # HELP database_observability_pg_index_stats_idx_scan_total Number of index scans initiated on this index + # TYPE database_observability_pg_index_stats_idx_scan_total counter + database_observability_pg_index_stats_idx_scan_total{datname="books_store",indexrelname="books_pkey",relname="books",schemaname="public"} 1.84e+08 + database_observability_pg_index_stats_idx_scan_total{datname="books_store",indexrelname="idx_books_title",relname="books",schemaname="public"} 0 + # HELP database_observability_pg_index_stats_size_bytes Total disk space used by this index, in bytes, labeled with whether it backs a primary key or unique constraint, or is partial + # TYPE database_observability_pg_index_stats_size_bytes gauge + database_observability_pg_index_stats_size_bytes{datname="books_store",indexrelname="books_pkey",is_partial="false",is_primary="true",is_unique="true",relname="books",schemaname="public"} 65536 + database_observability_pg_index_stats_size_bytes{datname="books_store",indexrelname="idx_books_title",is_partial="true",is_primary="false",is_unique="false",relname="books",schemaname="public"} 32768 ` require.NoError(t, testutil.CollectAndCompare(registry, strings.NewReader(expected))) diff --git a/internal/component/database_observability/postgres/collector/table_stats.go b/internal/component/database_observability/postgres/collector/table_stats.go index 320721a502c..843d7f6e89f 100644 --- a/internal/component/database_observability/postgres/collector/table_stats.go +++ b/internal/component/database_observability/postgres/collector/table_stats.go @@ -29,17 +29,17 @@ var tableLabels = []string{labelDatname, "schemaname", "relname"} var ( tableScanStatsSeqScanDesc = prometheus.NewDesc( - prometheus.BuildFQName("database_observability", "pg_stat_user_tables", "seq_scan"), + prometheus.BuildFQName("database_observability", "pg_table_stats", "seq_scan"), "Number of sequential scans initiated on this table", tableLabels, nil, ) tableScanStatsIdxScanDesc = prometheus.NewDesc( - prometheus.BuildFQName("database_observability", "pg_stat_user_tables", "idx_scan"), + prometheus.BuildFQName("database_observability", "pg_table_stats", "idx_scan"), "Number of index scans initiated on this table", tableLabels, nil, ) tableScanStatsNLiveTupDesc = prometheus.NewDesc( - prometheus.BuildFQName("database_observability", "pg_stat_user_tables", "n_live_tup"), + prometheus.BuildFQName("database_observability", "pg_table_stats", "n_live_tup"), "Estimated number of live rows", tableLabels, nil, ) diff --git a/internal/component/database_observability/postgres/collector/table_stats_test.go b/internal/component/database_observability/postgres/collector/table_stats_test.go index aa477dfa48d..66b2bad914e 100644 --- a/internal/component/database_observability/postgres/collector/table_stats_test.go +++ b/internal/component/database_observability/postgres/collector/table_stats_test.go @@ -46,15 +46,15 @@ func TestTableStats(t *testing.T) { ) expected := ` - # HELP database_observability_pg_stat_user_tables_idx_scan Number of index scans initiated on this table - # TYPE database_observability_pg_stat_user_tables_idx_scan counter - database_observability_pg_stat_user_tables_idx_scan{datname="books_store",relname="gen_adjectives",schemaname="public"} 0 - # HELP database_observability_pg_stat_user_tables_n_live_tup Estimated number of live rows - # TYPE database_observability_pg_stat_user_tables_n_live_tup gauge - database_observability_pg_stat_user_tables_n_live_tup{datname="books_store",relname="gen_adjectives",schemaname="public"} 500 - # HELP database_observability_pg_stat_user_tables_seq_scan Number of sequential scans initiated on this table - # TYPE database_observability_pg_stat_user_tables_seq_scan counter - database_observability_pg_stat_user_tables_seq_scan{datname="books_store",relname="gen_adjectives",schemaname="public"} 37154 + # HELP database_observability_pg_table_stats_idx_scan Number of index scans initiated on this table + # TYPE database_observability_pg_table_stats_idx_scan counter + database_observability_pg_table_stats_idx_scan{datname="books_store",relname="gen_adjectives",schemaname="public"} 0 + # HELP database_observability_pg_table_stats_n_live_tup Estimated number of live rows + # TYPE database_observability_pg_table_stats_n_live_tup gauge + database_observability_pg_table_stats_n_live_tup{datname="books_store",relname="gen_adjectives",schemaname="public"} 500 + # HELP database_observability_pg_table_stats_seq_scan Number of sequential scans initiated on this table + # TYPE database_observability_pg_table_stats_seq_scan counter + database_observability_pg_table_stats_seq_scan{datname="books_store",relname="gen_adjectives",schemaname="public"} 37154 ` require.NoError(t, testutil.CollectAndCompare(registry, strings.NewReader(expected))) From 8872665fa8082543f9a01053f45a48ab546bafa3 Mon Sep 17 00:00:00 2001 From: Gabriel Antunes Date: Fri, 11 Sep 2026 14:31:36 -0300 Subject: [PATCH 09/11] Add missing _total suffix to pg_table_stats counters seq_scan and idx_scan are registered as CounterValue (cumulative since the last stats reset), so per Prometheus convention they need a _total suffix, matching pg_index_stats_idx_scan_total. n_live_tup stays as-is since it's a GaugeValue, not cumulative. Co-Authored-By: Claude Sonnet 5 --- .../postgres/collector/table_stats.go | 4 ++-- .../postgres/collector/table_stats_test.go | 12 ++++++------ 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/internal/component/database_observability/postgres/collector/table_stats.go b/internal/component/database_observability/postgres/collector/table_stats.go index 843d7f6e89f..02231da2bd2 100644 --- a/internal/component/database_observability/postgres/collector/table_stats.go +++ b/internal/component/database_observability/postgres/collector/table_stats.go @@ -29,12 +29,12 @@ var tableLabels = []string{labelDatname, "schemaname", "relname"} var ( tableScanStatsSeqScanDesc = prometheus.NewDesc( - prometheus.BuildFQName("database_observability", "pg_table_stats", "seq_scan"), + prometheus.BuildFQName("database_observability", "pg_table_stats", "seq_scan_total"), "Number of sequential scans initiated on this table", tableLabels, nil, ) tableScanStatsIdxScanDesc = prometheus.NewDesc( - prometheus.BuildFQName("database_observability", "pg_table_stats", "idx_scan"), + prometheus.BuildFQName("database_observability", "pg_table_stats", "idx_scan_total"), "Number of index scans initiated on this table", tableLabels, nil, ) diff --git a/internal/component/database_observability/postgres/collector/table_stats_test.go b/internal/component/database_observability/postgres/collector/table_stats_test.go index 66b2bad914e..b111e60cce4 100644 --- a/internal/component/database_observability/postgres/collector/table_stats_test.go +++ b/internal/component/database_observability/postgres/collector/table_stats_test.go @@ -46,15 +46,15 @@ func TestTableStats(t *testing.T) { ) expected := ` - # HELP database_observability_pg_table_stats_idx_scan Number of index scans initiated on this table - # TYPE database_observability_pg_table_stats_idx_scan counter - database_observability_pg_table_stats_idx_scan{datname="books_store",relname="gen_adjectives",schemaname="public"} 0 + # HELP database_observability_pg_table_stats_idx_scan_total Number of index scans initiated on this table + # TYPE database_observability_pg_table_stats_idx_scan_total counter + database_observability_pg_table_stats_idx_scan_total{datname="books_store",relname="gen_adjectives",schemaname="public"} 0 # HELP database_observability_pg_table_stats_n_live_tup Estimated number of live rows # TYPE database_observability_pg_table_stats_n_live_tup gauge database_observability_pg_table_stats_n_live_tup{datname="books_store",relname="gen_adjectives",schemaname="public"} 500 - # HELP database_observability_pg_table_stats_seq_scan Number of sequential scans initiated on this table - # TYPE database_observability_pg_table_stats_seq_scan counter - database_observability_pg_table_stats_seq_scan{datname="books_store",relname="gen_adjectives",schemaname="public"} 37154 + # HELP database_observability_pg_table_stats_seq_scan_total Number of sequential scans initiated on this table + # TYPE database_observability_pg_table_stats_seq_scan_total counter + database_observability_pg_table_stats_seq_scan_total{datname="books_store",relname="gen_adjectives",schemaname="public"} 37154 ` require.NoError(t, testutil.CollectAndCompare(registry, strings.NewReader(expected))) From b58da18be46923669508aedd64f45d9bbb034750 Mon Sep 17 00:00:00 2001 From: Gabriel Antunes Date: Fri, 11 Sep 2026 15:00:16 -0300 Subject: [PATCH 10/11] Rename pg_table_stats n_live_tup to row_count row_count is the cross-engine name settled on for this gauge, so mysql's upcoming equivalent (mysql.innodb_table_stats.n_rows) can share it. Not row_total: this is a live snapshot that can go up or down, not a monotonic counter, so a _total suffix would be misleading. Co-Authored-By: Claude Sonnet 5 --- .../postgres/collector/table_stats.go | 14 +++++++------- .../postgres/collector/table_stats_test.go | 6 +++--- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/internal/component/database_observability/postgres/collector/table_stats.go b/internal/component/database_observability/postgres/collector/table_stats.go index 02231da2bd2..c4d7a65b766 100644 --- a/internal/component/database_observability/postgres/collector/table_stats.go +++ b/internal/component/database_observability/postgres/collector/table_stats.go @@ -38,9 +38,9 @@ var ( "Number of index scans initiated on this table", tableLabels, nil, ) - tableScanStatsNLiveTupDesc = prometheus.NewDesc( - prometheus.BuildFQName("database_observability", "pg_table_stats", "n_live_tup"), - "Estimated number of live rows", + tableStatsRowCountDesc = prometheus.NewDesc( + prometheus.BuildFQName("database_observability", "pg_table_stats", "row_count"), + "Estimated number of live rows in this table", tableLabels, nil, ) ) @@ -109,7 +109,7 @@ func (c *TableStats) Stop() { func (c *TableStats) Describe(ch chan<- *prometheus.Desc) { ch <- tableScanStatsSeqScanDesc ch <- tableScanStatsIdxScanDesc - ch <- tableScanStatsNLiveTupDesc + ch <- tableStatsRowCountDesc } // Collect implements prometheus.Collector. It runs synchronously at scrape @@ -146,16 +146,16 @@ func (c *TableStats) collectTableScanStats(ctx context.Context, dbName string, c for rows.Next() { var schemaname, relname string - var seqScan, idxScan, nLiveTup sql.NullInt64 + var seqScan, idxScan, rowCount sql.NullInt64 - if err := rows.Scan(&schemaname, &relname, &seqScan, &idxScan, &nLiveTup); err != nil { + if err := rows.Scan(&schemaname, &relname, &seqScan, &idxScan, &rowCount); err != nil { c.logger.Error("failed to scan pg_stat_user_tables row", "datname", dbName, "err", err) return } ch <- prometheus.MustNewConstMetric(tableScanStatsSeqScanDesc, prometheus.CounterValue, float64(seqScan.Int64), dbName, schemaname, relname) ch <- prometheus.MustNewConstMetric(tableScanStatsIdxScanDesc, prometheus.CounterValue, float64(idxScan.Int64), dbName, schemaname, relname) - ch <- prometheus.MustNewConstMetric(tableScanStatsNLiveTupDesc, prometheus.GaugeValue, float64(nLiveTup.Int64), dbName, schemaname, relname) + ch <- prometheus.MustNewConstMetric(tableStatsRowCountDesc, prometheus.GaugeValue, float64(rowCount.Int64), dbName, schemaname, relname) } if err := rows.Err(); err != nil { diff --git a/internal/component/database_observability/postgres/collector/table_stats_test.go b/internal/component/database_observability/postgres/collector/table_stats_test.go index b111e60cce4..3c318163fdd 100644 --- a/internal/component/database_observability/postgres/collector/table_stats_test.go +++ b/internal/component/database_observability/postgres/collector/table_stats_test.go @@ -49,9 +49,9 @@ func TestTableStats(t *testing.T) { # HELP database_observability_pg_table_stats_idx_scan_total Number of index scans initiated on this table # TYPE database_observability_pg_table_stats_idx_scan_total counter database_observability_pg_table_stats_idx_scan_total{datname="books_store",relname="gen_adjectives",schemaname="public"} 0 - # HELP database_observability_pg_table_stats_n_live_tup Estimated number of live rows - # TYPE database_observability_pg_table_stats_n_live_tup gauge - database_observability_pg_table_stats_n_live_tup{datname="books_store",relname="gen_adjectives",schemaname="public"} 500 + # HELP database_observability_pg_table_stats_row_count Estimated number of live rows in this table + # TYPE database_observability_pg_table_stats_row_count gauge + database_observability_pg_table_stats_row_count{datname="books_store",relname="gen_adjectives",schemaname="public"} 500 # HELP database_observability_pg_table_stats_seq_scan_total Number of sequential scans initiated on this table # TYPE database_observability_pg_table_stats_seq_scan_total counter database_observability_pg_table_stats_seq_scan_total{datname="books_store",relname="gen_adjectives",schemaname="public"} 37154 From c1390c151667606047ba68a3073a14ce9bc94e54 Mon Sep 17 00:00:00 2001 From: Gabriel Antunes Date: Fri, 11 Sep 2026 15:03:29 -0300 Subject: [PATCH 11/11] Align pg_index_stats_size_bytes HELP text with mysql's wording "the primary key" instead of "a primary key" -- a table has exactly one, in either engine. Found while auditing all new metrics across both PRs for cross-engine consistency. Co-Authored-By: Claude Sonnet 5 --- .../database_observability/postgres/collector/index_stats.go | 2 +- .../postgres/collector/index_stats_test.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/component/database_observability/postgres/collector/index_stats.go b/internal/component/database_observability/postgres/collector/index_stats.go index d73e09dc8ca..5ec55849235 100644 --- a/internal/component/database_observability/postgres/collector/index_stats.go +++ b/internal/component/database_observability/postgres/collector/index_stats.go @@ -39,7 +39,7 @@ var ( ) indexSizeBytesDesc = prometheus.NewDesc( prometheus.BuildFQName("database_observability", "pg_index_stats", "size_bytes"), - "Total disk space used by this index, in bytes, labeled with whether it backs a primary key or unique constraint, or is partial", + "Total disk space used by this index, in bytes, labeled with whether it backs the primary key or a unique constraint, or is partial", indexSizeLabels, nil, ) ) diff --git a/internal/component/database_observability/postgres/collector/index_stats_test.go b/internal/component/database_observability/postgres/collector/index_stats_test.go index 5957089d79c..9642550e35a 100644 --- a/internal/component/database_observability/postgres/collector/index_stats_test.go +++ b/internal/component/database_observability/postgres/collector/index_stats_test.go @@ -51,7 +51,7 @@ func TestIndexStats(t *testing.T) { # TYPE database_observability_pg_index_stats_idx_scan_total counter database_observability_pg_index_stats_idx_scan_total{datname="books_store",indexrelname="books_pkey",relname="books",schemaname="public"} 1.84e+08 database_observability_pg_index_stats_idx_scan_total{datname="books_store",indexrelname="idx_books_title",relname="books",schemaname="public"} 0 - # HELP database_observability_pg_index_stats_size_bytes Total disk space used by this index, in bytes, labeled with whether it backs a primary key or unique constraint, or is partial + # HELP database_observability_pg_index_stats_size_bytes Total disk space used by this index, in bytes, labeled with whether it backs the primary key or a unique constraint, or is partial # TYPE database_observability_pg_index_stats_size_bytes gauge database_observability_pg_index_stats_size_bytes{datname="books_store",indexrelname="books_pkey",is_partial="false",is_primary="true",is_unique="true",relname="books",schemaname="public"} 65536 database_observability_pg_index_stats_size_bytes{datname="books_store",indexrelname="idx_books_title",is_partial="true",is_primary="false",is_unique="false",relname="books",schemaname="public"} 32768