From be88b9e528da26438f7f28044b9e1aa114912b19 Mon Sep 17 00:00:00 2001 From: Gabriel Antunes Date: Fri, 11 Sep 2026 14:22:25 -0300 Subject: [PATCH 1/2] Add is_primary/is_unique labels to mysql_index_stats_size_bytes Mirrors the same addition on the postgres index_stats PR: fold these onto the existing size_bytes gauge as labels rather than a separate metric, since both are 1:1 with an index the same way schema/table/index already are. is_primary comes free from INDEX_NAME (MySQL always names the primary key index literally "PRIMARY"); is_unique is joined in from information_schema.statistics.NON_UNIQUE. Needed so the unused-index insight doesn't recommend dropping an index that's actually backing a primary key or unique constraint. Co-Authored-By: Claude Sonnet 5 --- .../mysql/collector/index_stats.go | 36 ++++++++++++++----- .../mysql/collector/index_stats_test.go | 10 +++--- 2 files changed, 34 insertions(+), 12 deletions(-) diff --git a/internal/component/database_observability/mysql/collector/index_stats.go b/internal/component/database_observability/mysql/collector/index_stats.go index 1abb8a0ba49..e0e2451a30b 100644 --- a/internal/component/database_observability/mysql/collector/index_stats.go +++ b/internal/component/database_observability/mysql/collector/index_stats.go @@ -5,6 +5,7 @@ import ( "database/sql" "fmt" "log/slog" + "strconv" "github.com/prometheus/client_golang/prometheus" "go.uber.org/atomic" @@ -21,13 +22,26 @@ const selectIndexIOWaits = ` WHERE INDEX_NAME IS NOT NULL AND OBJECT_SCHEMA NOT IN %s` // mysql.innodb_index_stats holds InnoDB's persistent optimizer statistics, -// refreshed by MySQL itself. +// refreshed by MySQL itself. NON_UNIQUE comes from information_schema.statistics, +// joined on seq_in_index = 1 since uniqueness is a property of the index, not of +// any one column within it, and would otherwise repeat once per indexed column. const selectIndexSizeBytes = ` - SELECT database_name, table_name, index_name, stat_value * @@innodb_page_size - FROM mysql.innodb_index_stats - WHERE stat_name = 'size' AND database_name NOT IN %s` + SELECT + s.database_name, + s.table_name, + s.index_name, + s.stat_value * @@innodb_page_size, + stats.NON_UNIQUE + FROM mysql.innodb_index_stats s + LEFT JOIN information_schema.statistics stats + ON stats.TABLE_SCHEMA = s.database_name + AND stats.TABLE_NAME = s.table_name + AND stats.INDEX_NAME = s.index_name + AND stats.SEQ_IN_INDEX = 1 + WHERE s.stat_name = 'size' AND s.database_name NOT IN %s` var indexLabels = []string{labelSchema, labelTable, "index"} +var indexSizeLabels = append(append([]string{}, indexLabels...), "is_primary", "is_unique") var ( indexStatsIdxFetchDesc = prometheus.NewDesc( @@ -37,8 +51,8 @@ var ( ) indexStatsSizeBytesDesc = prometheus.NewDesc( prometheus.BuildFQName("database_observability", "mysql_index_stats", "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 the primary key or a unique constraint", + indexSizeLabels, nil, ) ) @@ -142,13 +156,19 @@ func (c *IndexStats) collectIndexSize(ctx context.Context, ch chan<- prometheus. for rows.Next() { var databaseName, tableName, indexName string var sizeBytes float64 + var nonUnique sql.NullInt64 - if err := rows.Scan(&databaseName, &tableName, &indexName, &sizeBytes); err != nil { + if err := rows.Scan(&databaseName, &tableName, &indexName, &sizeBytes, &nonUnique); err != nil { c.logger.Error("failed to scan mysql.innodb_index_stats row", "err", err) return } - ch <- prometheus.MustNewConstMetric(indexStatsSizeBytesDesc, prometheus.GaugeValue, sizeBytes, databaseName, tableName, indexName) + isPrimary := indexName == "PRIMARY" + isUnique := nonUnique.Valid && nonUnique.Int64 == 0 + + ch <- prometheus.MustNewConstMetric(indexStatsSizeBytesDesc, prometheus.GaugeValue, sizeBytes, + databaseName, tableName, indexName, + strconv.FormatBool(isPrimary), strconv.FormatBool(isUnique)) } if err := rows.Err(); err != nil { diff --git a/internal/component/database_observability/mysql/collector/index_stats_test.go b/internal/component/database_observability/mysql/collector/index_stats_test.go index 0d85b656f88..6471cd83e55 100644 --- a/internal/component/database_observability/mysql/collector/index_stats_test.go +++ b/internal/component/database_observability/mysql/collector/index_stats_test.go @@ -37,17 +37,19 @@ func TestIndexStats(t *testing.T) { ) mock.ExpectQuery(fmt.Sprintf(selectIndexSizeBytes, exclusionClause)).WithoutArgs().RowsWillBeClosed(). WillReturnRows( - sqlmock.NewRows([]string{"database_name", "table_name", "index_name", "size_bytes"}). - AddRow("books_store", "books", "idx_books_title", 14196736), + sqlmock.NewRows([]string{"database_name", "table_name", "index_name", "size_bytes", "non_unique"}). + AddRow("books_store", "books", "PRIMARY", 65536, 0). + AddRow("books_store", "books", "idx_books_title", 14196736, 1), ) expected := ` # HELP database_observability_mysql_index_stats_idx_fetch_total Count of index I/O wait events for fetch operations # TYPE database_observability_mysql_index_stats_idx_fetch_total counter database_observability_mysql_index_stats_idx_fetch_total{index="idx_books_title",schema="books_store",table="books"} 0 - # HELP database_observability_mysql_index_stats_size_bytes Total disk space used by this index, in bytes + # HELP database_observability_mysql_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 # TYPE database_observability_mysql_index_stats_size_bytes gauge - database_observability_mysql_index_stats_size_bytes{index="idx_books_title",schema="books_store",table="books"} 1.4196736e+07 + database_observability_mysql_index_stats_size_bytes{index="PRIMARY",is_primary="true",is_unique="true",schema="books_store",table="books"} 65536 + database_observability_mysql_index_stats_size_bytes{index="idx_books_title",is_primary="false",is_unique="false",schema="books_store",table="books"} 1.4196736e+07 ` require.NoError(t, testutil.CollectAndCompare(registry, strings.NewReader(expected))) From 97b10921240ee66a2ae1910cf72d459d52a10303 Mon Sep 17 00:00:00 2001 From: Gabriel Antunes Date: Fri, 11 Sep 2026 15:01:58 -0300 Subject: [PATCH 2/2] Add mysql_table_stats_row_count, mirroring pg_table_stats_row_count Sourced from mysql.innodb_table_stats.n_rows, the sibling table to mysql.innodb_index_stats already used by index_stats.go for index size -- same refresh mechanism, same grant requirement already relied on there. Without a row-count signal, the missing-index insight can't tell a table that's genuinely being hammered with full scans from one that's just small enough that a full scan doesn't matter, the same gap this closed on the postgres side. Co-Authored-By: Claude Sonnet 5 --- .../mysql/collector/table_stats.go | 57 +++++++++++++++++-- .../mysql/collector/table_stats_test.go | 8 +++ 2 files changed, 61 insertions(+), 4 deletions(-) diff --git a/internal/component/database_observability/mysql/collector/table_stats.go b/internal/component/database_observability/mysql/collector/table_stats.go index 3712b2f8fbc..b783b195d18 100644 --- a/internal/component/database_observability/mysql/collector/table_stats.go +++ b/internal/component/database_observability/mysql/collector/table_stats.go @@ -20,15 +20,32 @@ const selectTableIOWaitsNoIndex = ` FROM performance_schema.table_io_waits_summary_by_index_usage WHERE INDEX_NAME IS NULL AND OBJECT_SCHEMA NOT IN %s` +// mysql.innodb_table_stats holds InnoDB's persistent optimizer statistics, +// refreshed by MySQL itself -- the sibling table to mysql.innodb_index_stats, +// already used by index_stats.go for index size. +const selectTableRowCount = ` + SELECT database_name, table_name, n_rows + FROM mysql.innodb_table_stats + WHERE database_name NOT IN %s` + const ( labelSchema = "schema" labelTable = "table" ) -var tableStatsNoIdxFetchDesc = prometheus.NewDesc( - prometheus.BuildFQName("database_observability", "mysql_table_stats", "no_idx_fetch_total"), - "Count of index I/O wait events for fetch operations that did not use an index", - []string{labelSchema, labelTable}, nil, +var tableLabels = []string{labelSchema, labelTable} + +var ( + tableStatsNoIdxFetchDesc = prometheus.NewDesc( + prometheus.BuildFQName("database_observability", "mysql_table_stats", "no_idx_fetch_total"), + "Count of index I/O wait events for fetch operations that did not use an index", + tableLabels, nil, + ) + tableStatsRowCountDesc = prometheus.NewDesc( + prometheus.BuildFQName("database_observability", "mysql_table_stats", "row_count"), + "Estimated number of rows in this table", + tableLabels, nil, + ) ) type TableStatsArguments struct { @@ -82,12 +99,18 @@ func (c *TableStats) Stop() { // Describe implements prometheus.Collector. func (c *TableStats) Describe(ch chan<- *prometheus.Desc) { ch <- tableStatsNoIdxFetchDesc + ch <- tableStatsRowCountDesc } // Collect implements prometheus.Collector. It runs synchronously at scrape time. func (c *TableStats) Collect(ch chan<- prometheus.Metric) { ctx := context.Background() + c.collectNoIdxFetch(ctx, ch) + c.collectRowCount(ctx, ch) +} + +func (c *TableStats) collectNoIdxFetch(ctx context.Context, ch chan<- prometheus.Metric) { query := fmt.Sprintf(selectTableIOWaitsNoIndex, buildExcludedSchemasClause(c.excludeSchemas)) rows, err := c.dbConnection.QueryContext(ctx, query) if err != nil { @@ -112,3 +135,29 @@ func (c *TableStats) Collect(ch chan<- prometheus.Metric) { c.logger.Error("error iterating table_io_waits_summary_by_index_usage rows", "err", err) } } + +func (c *TableStats) collectRowCount(ctx context.Context, ch chan<- prometheus.Metric) { + query := fmt.Sprintf(selectTableRowCount, buildExcludedSchemasClause(c.excludeSchemas)) + rows, err := c.dbConnection.QueryContext(ctx, query) + if err != nil { + c.logger.Error("failed to query mysql.innodb_table_stats", "err", err) + return + } + defer rows.Close() + + for rows.Next() { + var databaseName, tableName string + var rowCount int64 + + if err := rows.Scan(&databaseName, &tableName, &rowCount); err != nil { + c.logger.Error("failed to scan mysql.innodb_table_stats row", "err", err) + return + } + + ch <- prometheus.MustNewConstMetric(tableStatsRowCountDesc, prometheus.GaugeValue, float64(rowCount), databaseName, tableName) + } + + if err := rows.Err(); err != nil { + c.logger.Error("error iterating mysql.innodb_table_stats rows", "err", err) + } +} diff --git a/internal/component/database_observability/mysql/collector/table_stats_test.go b/internal/component/database_observability/mysql/collector/table_stats_test.go index c32cf3c6788..6bfb487dee9 100644 --- a/internal/component/database_observability/mysql/collector/table_stats_test.go +++ b/internal/component/database_observability/mysql/collector/table_stats_test.go @@ -35,11 +35,19 @@ func TestTableStats(t *testing.T) { sqlmock.NewRows([]string{"OBJECT_SCHEMA", "OBJECT_NAME", "COUNT_FETCH"}). AddRow("books_store", "books", 39), ) + mock.ExpectQuery(fmt.Sprintf(selectTableRowCount, exclusionClause)).WithoutArgs().RowsWillBeClosed(). + WillReturnRows( + sqlmock.NewRows([]string{"database_name", "table_name", "n_rows"}). + AddRow("books_store", "books", 500), + ) expected := ` # HELP database_observability_mysql_table_stats_no_idx_fetch_total Count of index I/O wait events for fetch operations that did not use an index # TYPE database_observability_mysql_table_stats_no_idx_fetch_total counter database_observability_mysql_table_stats_no_idx_fetch_total{schema="books_store",table="books"} 39 + # HELP database_observability_mysql_table_stats_row_count Estimated number of rows in this table + # TYPE database_observability_mysql_table_stats_row_count gauge + database_observability_mysql_table_stats_row_count{schema="books_store",table="books"} 500 ` require.NoError(t, testutil.CollectAndCompare(registry, strings.NewReader(expected)))