diff --git a/README.md b/README.md index 29be170aa..790251505 100644 --- a/README.md +++ b/README.md @@ -96,6 +96,8 @@ collect.heartbeat | 5.1 | C collect.heartbeat.database | 5.1 | Database from where to collect heartbeat data. (default: heartbeat) collect.heartbeat.table | 5.1 | Table from where to collect heartbeat data. (default: heartbeat) collect.heartbeat.utc | 5.1 | Use UTC for timestamps of the current server (`pt-heartbeat` is called with `--utc`). (default: false) +collect.mysql.innodb_index_stats | 5.1 | Collect InnoDB metrics from mysql.innodb_index_stats (default: OFF) +collect.mysql.innodb_table_stats | 5.1 | Collect InnoDB metrics from mysql.innodb_table_stats (default: OFF) ### General Flags diff --git a/collector/mysql_innodb_index_stats.go b/collector/mysql_innodb_index_stats.go new file mode 100644 index 000000000..8a601b253 --- /dev/null +++ b/collector/mysql_innodb_index_stats.go @@ -0,0 +1,106 @@ +// Copyright 2023 The Prometheus Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Scrape `mysql.innodb_index_stats`. + +package collector + +import ( + "context" + "database/sql" + "fmt" + + "github.com/go-kit/kit/log" + "github.com/prometheus/client_golang/prometheus" +) + +const mysqlIndexStatsQuery = ` + SELECT + database_name, + table_name, + index_name, + stat_name, + stat_value + FROM mysql.innodb_index_stats + ` + +var ( + indexStatLabelNames = []string{"database_name", "table_name", "index_name", "stat_name"} +) + +// Metric descriptors. +var ( + indexStatsValueDesc = prometheus.NewDesc( + prometheus.BuildFQName(namespace, innodb, "innodb_index_stats_stat_value"), + "Size of the InnoDB index in bytes.", + indexStatLabelNames, nil) +) + +type ScrapeMysqlIndexStat struct{} + +// Name of the Scraper. Should be unique. +func (ScrapeMysqlIndexStat) Name() string { + return "mysql.innodb_index_stats" +} + +// Help describes the role of the Scraper. +func (ScrapeMysqlIndexStat) Help() string { + return "Collect data from mysql.innodb_index_stats" +} + +// Version of MySQL from which scraper is available. +func (ScrapeMysqlIndexStat) Version() float64 { + return 5.1 +} + +// Scrape collects data from database connection and sends it over channel as prometheus metric. +func (ScrapeMysqlIndexStat) Scrape(ctx context.Context, db *sql.DB, ch chan<- prometheus.Metric, logger log.Logger) error { + var ( + indexStatRows *sql.Rows + err error + ) + indexStatsQuery := fmt.Sprint(mysqlIndexStatsQuery) + indexStatRows, err = db.QueryContext(ctx, indexStatsQuery) + if err != nil { + return err + } + defer indexStatRows.Close() + + var ( + database_name string + table_name string + index_name string + stat_name string + stat_value uint32 + ) + + for indexStatRows.Next() { + err = indexStatRows.Scan( + &database_name, + &table_name, + &index_name, + &stat_name, + &stat_value, + ) + + if err != nil { + return err + } + + ch <- prometheus.MustNewConstMetric(indexStatsValueDesc, prometheus.GaugeValue, float64(stat_value), database_name, table_name, index_name, stat_name) + } + + return nil +} + +var _ Scraper = ScrapeMysqlIndexStat{} diff --git a/collector/mysql_innodb_index_stats_test.go b/collector/mysql_innodb_index_stats_test.go new file mode 100644 index 000000000..b086a983e --- /dev/null +++ b/collector/mysql_innodb_index_stats_test.go @@ -0,0 +1,61 @@ +// Copyright 2023 The Prometheus Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package collector + +import ( + "context" + "testing" + + "github.com/DATA-DOG/go-sqlmock" + "github.com/go-kit/kit/log" + "github.com/prometheus/client_golang/prometheus" + dto "github.com/prometheus/client_model/go" + "github.com/smartystreets/goconvey/convey" +) + +func TestScrapeMysqlIndexStat(t *testing.T) { + db, mock, err := sqlmock.New() + if err != nil { + t.Fatalf("error opening a stub database connection: %s", err) + } + defer db.Close() + + columns := []string{"database_name", "table_name", "index_name", "stat_name", "stat_value"} + rows := sqlmock.NewRows(columns). + AddRow("mysql", "gtid_slave_pos", "PRIMARY", "n_diff_pfx01", 0) + mock.ExpectQuery(sanitizeQuery(mysqlIndexStatsQuery)).WillReturnRows(rows) + + ch := make(chan prometheus.Metric) + go func() { + if err = (ScrapeMysqlIndexStat{}).Scrape(context.Background(), db, ch, log.NewNopLogger()); err != nil { + t.Errorf("error calling function on test: %s", err) + } + close(ch) + }() + + expected := []MetricResult{ + {labels: labelMap{"database_name": "mysql", "index_name": "PRIMARY", "stat_name": "n_diff_pfx01", "table_name": "gtid_slave_pos"}, value: 0, metricType: dto.MetricType_GAUGE}, + } + convey.Convey("Metrics comparison", t, func() { + for _, expect := range expected { + got := readMetric(<-ch) + convey.So(expect, convey.ShouldResemble, got) + } + }) + + // Ensure all SQL queries were executed + if err := mock.ExpectationsWereMet(); err != nil { + t.Errorf("there were unfulfilled exceptions: %s", err) + } +} diff --git a/collector/mysql_innodb_table_stats.go b/collector/mysql_innodb_table_stats.go new file mode 100644 index 000000000..e2a2f381b --- /dev/null +++ b/collector/mysql_innodb_table_stats.go @@ -0,0 +1,116 @@ +// Copyright 2023 The Prometheus Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Scrape `mysql.innodb_table_stats`. + +package collector + +import ( + "context" + "database/sql" + "fmt" + + "github.com/go-kit/kit/log" + "github.com/prometheus/client_golang/prometheus" +) + +const mysqlTableStatsQuery = ` + SELECT + database_name, + table_name, + n_rows, + clustered_index_size, + sum_of_other_index_sizes + FROM mysql.innodb_table_stats + ` + +var ( + tableStatLabelNames = []string{"database_name", "table_name"} +) + +// Metric descriptors. +var ( + nRowsDesc = prometheus.NewDesc( + prometheus.BuildFQName(namespace, innodb, "innodb_table_stats_n_rows"), + "Number of rows in the table.", + tableStatLabelNames, nil) + clusteredIndexSizeDesc = prometheus.NewDesc( + prometheus.BuildFQName(namespace, innodb, "innodb_table_stats_clustered_index_size"), + "The size of the primary index, in pages.", + tableStatLabelNames, nil) + sumOfOtherIndexSizesDesc = prometheus.NewDesc( + prometheus.BuildFQName(namespace, innodb, "innodb_table_stats_sum_of_other_index_sizes"), + "The total size of other (non-primary) indexes, in pages.", + tableStatLabelNames, nil) +) + +type ScrapeMysqlTableStat struct{} + +// Name of the Scraper. Should be unique. +func (ScrapeMysqlTableStat) Name() string { + return "mysql.innodb_table_stats" +} + +// Help describes the role of the Scraper. +func (ScrapeMysqlTableStat) Help() string { + return "Collect data from mysql.innodb_table_stats" +} + +// Version of MySQL from which scraper is available. +func (ScrapeMysqlTableStat) Version() float64 { + return 5.1 +} + +// Scrape collects data from database connection and sends it over channel as prometheus metric. +func (ScrapeMysqlTableStat) Scrape(ctx context.Context, db *sql.DB, ch chan<- prometheus.Metric, logger log.Logger) error { + var ( + tableStatRows *sql.Rows + err error + ) + tableStatsQuery := fmt.Sprint(mysqlTableStatsQuery) + tableStatRows, err = db.QueryContext(ctx, tableStatsQuery) + if err != nil { + return err + } + defer tableStatRows.Close() + + var ( + database_name string + table_name string + n_rows uint32 + clustered_index_size uint32 + sum_of_other_index_sizes uint32 + ) + + for tableStatRows.Next() { + err = tableStatRows.Scan( + &database_name, + &table_name, + &n_rows, + &clustered_index_size, + &sum_of_other_index_sizes, + ) + + if err != nil { + return err + } + + ch <- prometheus.MustNewConstMetric(nRowsDesc, prometheus.GaugeValue, float64(n_rows), database_name, table_name) + ch <- prometheus.MustNewConstMetric(clusteredIndexSizeDesc, prometheus.GaugeValue, float64(clustered_index_size), database_name, table_name) + ch <- prometheus.MustNewConstMetric(sumOfOtherIndexSizesDesc, prometheus.GaugeValue, float64(sum_of_other_index_sizes), database_name, table_name) + } + + return nil +} + +var _ Scraper = ScrapeMysqlTableStat{} diff --git a/collector/mysql_innodb_table_stats_test.go b/collector/mysql_innodb_table_stats_test.go new file mode 100644 index 000000000..8ccc94b0b --- /dev/null +++ b/collector/mysql_innodb_table_stats_test.go @@ -0,0 +1,63 @@ +// Copyright 2023 The Prometheus Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package collector + +import ( + "context" + "testing" + + "github.com/DATA-DOG/go-sqlmock" + "github.com/go-kit/kit/log" + "github.com/prometheus/client_golang/prometheus" + dto "github.com/prometheus/client_model/go" + "github.com/smartystreets/goconvey/convey" +) + +func TestScrapeMysqlTableStat(t *testing.T) { + db, mock, err := sqlmock.New() + if err != nil { + t.Fatalf("error opening a stub database connection: %s", err) + } + defer db.Close() + + columns := []string{"database_name", "table_name", "n_rows", "clustered_index_size", "sum_of_other_index_sizes"} + rows := sqlmock.NewRows(columns). + AddRow("mysql", "gtid_slave_pos", 0, 1, 0) + mock.ExpectQuery(sanitizeQuery(mysqlTableStatsQuery)).WillReturnRows(rows) + + ch := make(chan prometheus.Metric) + go func() { + if err = (ScrapeMysqlTableStat{}).Scrape(context.Background(), db, ch, log.NewNopLogger()); err != nil { + t.Errorf("error calling function on test: %s", err) + } + close(ch) + }() + + expected := []MetricResult{ + {labels: labelMap{"database_name": "mysql", "table_name": "gtid_slave_pos"}, value: 0, metricType: dto.MetricType_GAUGE}, + {labels: labelMap{"database_name": "mysql", "table_name": "gtid_slave_pos"}, value: 1, metricType: dto.MetricType_GAUGE}, + {labels: labelMap{"database_name": "mysql", "table_name": "gtid_slave_pos"}, value: 0, metricType: dto.MetricType_GAUGE}, + } + convey.Convey("Metrics comparison", t, func() { + for _, expect := range expected { + got := readMetric(<-ch) + convey.So(expect, convey.ShouldResemble, got) + } + }) + + // Ensure all SQL queries were executed + if err := mock.ExpectationsWereMet(); err != nil { + t.Errorf("there were unfulfilled exceptions: %s", err) + } +} diff --git a/mysqld_exporter.go b/mysqld_exporter.go index 290304787..88f36d2e0 100644 --- a/mysqld_exporter.go +++ b/mysqld_exporter.go @@ -100,6 +100,8 @@ var scrapers = map[collector.Scraper]bool{ collector.ScrapeHeartbeat{}: false, collector.ScrapeSlaveHosts{}: false, collector.ScrapeReplicaHost{}: false, + collector.ScrapeMysqlIndexStat{}: false, + collector.ScrapeMysqlTableStat{}: false, } func parseMycnf(config interface{}) (string, error) {