diff --git a/README.md b/README.md index 8c0bbf3f..a695a6e7 100644 --- a/README.md +++ b/README.md @@ -96,6 +96,7 @@ collect.engine_innodb_status | 5.1 | C collect.engine_tokudb_status | 5.6 | Collect from SHOW ENGINE TOKUDB STATUS. collect.global_status | 5.1 | Collect from SHOW GLOBAL STATUS (Enabled by default) collect.global_variables | 5.1 | Collect from SHOW GLOBAL VARIABLES (Enabled by default) +collect.gtid_transactions | 5.6 | Collect a transaction count from @gtid_executed. collect.heartbeat | 5.1 | Collect from [heartbeat](#heartbeat). 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) diff --git a/collector/mysql_gtid.go b/collector/mysql_gtid.go new file mode 100644 index 00000000..a9f3532d --- /dev/null +++ b/collector/mysql_gtid.go @@ -0,0 +1,87 @@ +// Copyright 2025 The Prometheus Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Scrape a transaction counter from the gtid_executed + +package collector + +import ( + "context" + "log/slog" + + "github.com/prometheus/client_golang/prometheus" + "github.com/sjmudd/mysqlgtid" +) + +const ( + // transactions is the Metric subsystem we use. + prometheusSubsystem = "gtid" + prometheusName = "transactions" + // gtidTransactionCountQuery is the query used to fetch gtid_executed. + // With this value we can convert it to an incremental transaction counter. + gtidTransactionCountQuery = "SELECT @@gtid_executed" +) + +var ( + // Metric descriptors. + GtidTransactionCounterDesc = prometheus.NewDesc( + prometheus.BuildFQName(namespace, prometheusSubsystem, prometheusName), + "Number of GTID transactions", + []string{}, nil, + ) +) + +// ScrapeGtidExecuted scrapes transaction count from @@gtid_executed. +type ScrapeGtidExecuted struct{} + +// Name of the Scraper. Should be unique. +func (ScrapeGtidExecuted) Name() string { + return "gtid_transactions" +} + +// Help describes the role of the Scraper. +func (ScrapeGtidExecuted) Help() string { + return "Number of GTID transactions" +} + +// Version of MySQL from which scraper is available. +func (ScrapeGtidExecuted) Version() float64 { + return 5.6 +} + +// Scrape collects data from database connection and sends it over channel as prometheus metric. +func (ScrapeGtidExecuted) Scrape(ctx context.Context, instance *instance, ch chan<- prometheus.Metric, logger *slog.Logger) error { + var gtidExecuted string + + db := instance.getDB() + if err := db.QueryRowContext(ctx, gtidTransactionCountQuery).Scan(>idExecuted); err != nil { + return err + } + + // convert into a counter + gtidExecutedTransactionCounterIntVal, err := mysqlgtid.TransactionCount(gtidExecuted) + if err != nil { + return err + } + + ch <- prometheus.MustNewConstMetric( + GtidTransactionCounterDesc, + prometheus.CounterValue, + float64(gtidExecutedTransactionCounterIntVal), + ) + + return nil +} + +// check interface +var _ Scraper = ScrapeGtidExecuted{} diff --git a/collector/mysql_gtid_test.go b/collector/mysql_gtid_test.go new file mode 100644 index 00000000..e821f11d --- /dev/null +++ b/collector/mysql_gtid_test.go @@ -0,0 +1,90 @@ +// Copyright 2025 The Prometheus Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package collector + +import ( + "context" + "testing" + + "github.com/DATA-DOG/go-sqlmock" + "github.com/prometheus/client_golang/prometheus" + dto "github.com/prometheus/client_model/go" + "github.com/prometheus/common/promslog" + "github.com/smartystreets/goconvey/convey" +) + +// TestScrapeGtidExecuted tests ScrapeGtidExecuted behaviour +func TestScrapeGtidExecuted(t *testing.T) { + + tests := []struct { + name string + gtidSet string + expected float64 + }{ + {"empty_set", "", 0}, + {"single_uuid_and_range", `uuid1:1-1000`, 1000}, + {"multiple_uuid_single_range", `uuid1:1-1000, + uuid1:1001-2000`, 2000}, + {"single_uuid_with_ranges", `uuid1:1-1000,2001-4000`, 3000}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + db, mock, err := sqlmock.New() + if err != nil { + t.Fatalf("error opening a stub database connection: %s", err) + } + defer db.Close() + + inst := &instance{db: db} + + columns := []string{"@@gtid_executed"} + rows := sqlmock.NewRows(columns). + AddRow(test.gtidSet) + mock.ExpectQuery(gtidTransactionCountQuery). + WithArgs(). + WillReturnRows(rows) + + ch := make(chan prometheus.Metric) + go func() { + if err = (ScrapeGtidExecuted{}).Scrape(context.Background(), inst, ch, promslog.NewNopLogger()); err != nil { + t.Errorf("error calling function on test: %s", err) + } + close(ch) + }() + + counterExpected := []MetricResult{ + { + labels: labelMap{}, + value: test.expected, + metricType: dto.MetricType_COUNTER, + }, + } + + convey.Convey("Metrics comparison", t, func() { + for _, expect := range counterExpected { + got := readMetric(<-ch) + convey.So(got, convey.ShouldResemble, expect) + } + }) + + // Ensure all SQL queries were executed + if err := mock.ExpectationsWereMet(); err != nil { + t.Errorf("there were unfulfilled expectations: %s", err) + } + }) + + } + +} diff --git a/go.mod b/go.mod index 9f712396..5c1cdcbd 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/prometheus/mysqld_exporter -go 1.23.0 +go 1.23.3 require ( github.com/DATA-DOG/go-sqlmock v1.5.2 @@ -13,6 +13,7 @@ require ( github.com/prometheus/client_model v0.6.2 github.com/prometheus/common v0.65.0 github.com/prometheus/exporter-toolkit v0.13.2 + github.com/sjmudd/mysqlgtid v0.1.0 github.com/smartystreets/goconvey v1.8.1 gopkg.in/ini.v1 v1.67.0 ) diff --git a/go.sum b/go.sum index c2f96b99..583b9cf7 100644 --- a/go.sum +++ b/go.sum @@ -61,6 +61,8 @@ github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0leargg github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk= github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= +github.com/sjmudd/mysqlgtid v0.1.0 h1:L/jycKaCUcO0vGBT1UuUuuZR6XmN/3SQpbO7IarPVu0= +github.com/sjmudd/mysqlgtid v0.1.0/go.mod h1:+guZnxmozw/hUgPZXSSryyRL2o3pBNC6aoVh1QlNg0E= github.com/smarty/assertions v1.15.0 h1:cR//PqUBUiQRakZWqBiFFQ9wb8emQGDb0HeGdqGByCY= github.com/smarty/assertions v1.15.0/go.mod h1:yABtdzeQs6l1brC900WlRNwj6ZR55d7B+E8C6HtKdec= github.com/smartystreets/goconvey v1.8.1 h1:qGjIddxOk4grTu9JPOU31tVfq3cNdBlNa5sSznIX1xY= diff --git a/mysqld_exporter.go b/mysqld_exporter.go index 75bd5dbf..a522f6cc 100644 --- a/mysqld_exporter.go +++ b/mysqld_exporter.go @@ -105,6 +105,7 @@ var scrapers = map[collector.Scraper]bool{ collector.ScrapeSlaveHosts{}: false, collector.ScrapeReplicaHost{}: false, collector.ScrapeRocksDBPerfContext{}: false, + collector.ScrapeGtidExecuted{}: false, } func filterScrapers(scrapers []collector.Scraper, collectParams []string) []collector.Scraper {