diff --git a/Docs/prometheus_exporter.md b/Docs/prometheus_exporter.md new file mode 100644 index 000000000000..543b71dbb439 --- /dev/null +++ b/Docs/prometheus_exporter.md @@ -0,0 +1,65 @@ +# Prometheus Exporter Plugin + +Embedded Prometheus metrics exporter for VillageSQL. + +## Platform Support + +The plugin is built and supported on Linux only. The current implementation +uses Linux-specific networking/thread wakeup primitives, so non-Linux builds do +not compile the module. + +## Quick Start + +### 1. Verify the plugin can be loaded + +```sql +INSTALL PLUGIN prometheus_exporter SONAME 'prometheus_exporter.so'; +SELECT PLUGIN_NAME, PLUGIN_STATUS +FROM INFORMATION_SCHEMA.PLUGINS +WHERE PLUGIN_NAME = 'prometheus_exporter'; +UNINSTALL PLUGIN prometheus_exporter; +``` + +This confirms the shared object is present and loadable, but it does not start +the HTTP endpoint by itself. + +### 2. Enable the HTTP endpoint at server startup + +```ini +[mysqld] +plugin-load=prometheus_exporter=prometheus_exporter.so +prometheus-exporter-enabled=ON +prometheus-exporter-port=9104 +prometheus-exporter-bind-address=127.0.0.1 +``` + +Use a numeric IPv4 bind address such as `127.0.0.1`. The current listener does +not accept hostnames like `localhost`. + +Restart the server, then verify: + +```bash +curl http://127.0.0.1:9104/metrics +``` + +## Replica Metrics + +Replica metrics are emitted from `SHOW REPLICA STATUS`. + +- Single-channel replication exposes one sample per metric family. +- Multi-channel replication exposes one sample per named channel using a + `channel="..."` label. +- The default unnamed channel remains unlabeled. + +Example: + +```text +# TYPE mysql_replica_io_running gauge +mysql_replica_io_running{channel="channel_1"} 1 +mysql_replica_io_running{channel="channel_3"} 1 +``` + +## Reference + +For full configuration, architecture, and collector details, see +[plugin/prometheus_exporter/README.md](../plugin/prometheus_exporter/README.md). diff --git a/mysql-test/include/plugin.defs b/mysql-test/include/plugin.defs index feb8d3b95237..b48fb39ad6b2 100644 --- a/mysql-test/include/plugin.defs +++ b/mysql-test/include/plugin.defs @@ -192,3 +192,6 @@ component_test_execute_prepared_statement plugin_output_directory no COMPONEN # component_test_execute_regular_statement component_test_execute_regular_statement plugin_output_directory no COMPONENT_TEST_EXECUTE_REGULAR_STATEMENT + +# Prometheus exporter plugin +prometheus_exporter plugin_output_directory no PROMETHEUS_EXPORTER_PLUGIN prometheus_exporter diff --git a/mysql-test/suite/prometheus_exporter/inc/have_prometheus_exporter_plugin.inc b/mysql-test/suite/prometheus_exporter/inc/have_prometheus_exporter_plugin.inc new file mode 100644 index 000000000000..4acccbf85f31 --- /dev/null +++ b/mysql-test/suite/prometheus_exporter/inc/have_prometheus_exporter_plugin.inc @@ -0,0 +1,5 @@ +--disable_query_log +if (!$PROMETHEUS_EXPORTER_PLUGIN) { + --skip prometheus_exporter plugin is not built on this platform or not available in the mysql-test environment +} +--enable_query_log diff --git a/mysql-test/suite/prometheus_exporter/r/basic.result b/mysql-test/suite/prometheus_exporter/r/basic.result new file mode 100644 index 000000000000..2d1c7f79dc7b --- /dev/null +++ b/mysql-test/suite/prometheus_exporter/r/basic.result @@ -0,0 +1,17 @@ +# Install prometheus_exporter plugin +INSTALL PLUGIN prometheus_exporter SONAME 'PROMETHEUS_EXPORTER_PLUGIN'; +# Verify system variables exist with correct defaults +SHOW VARIABLES LIKE 'prometheus_exporter%'; +Variable_name Value +prometheus_exporter_bind_address 127.0.0.1 +prometheus_exporter_enabled OFF +prometheus_exporter_port 9104 +prometheus_exporter_security_user root +# Verify status variables exist (all zero when disabled) +SHOW STATUS LIKE 'Prometheus_exporter%'; +Variable_name Value +Prometheus_exporter_errors_total 0 +Prometheus_exporter_requests_total 0 +Prometheus_exporter_scrape_duration_microseconds 0 +# Uninstall plugin +UNINSTALL PLUGIN prometheus_exporter; diff --git a/mysql-test/suite/prometheus_exporter/r/binlog.result b/mysql-test/suite/prometheus_exporter/r/binlog.result new file mode 100644 index 000000000000..effebf7f92f7 --- /dev/null +++ b/mysql-test/suite/prometheus_exporter/r/binlog.result @@ -0,0 +1,6 @@ +# Verify binlog metrics are present +# TYPE mysql_binlog_file_count gauge +# Verify binlog size metric +# TYPE mysql_binlog_size_bytes_total gauge +# Verify values are numeric +mysql_binlog_file_count NUM diff --git a/mysql-test/suite/prometheus_exporter/r/format_validation.result b/mysql-test/suite/prometheus_exporter/r/format_validation.result new file mode 100644 index 000000000000..2b8cf62b9ab9 --- /dev/null +++ b/mysql-test/suite/prometheus_exporter/r/format_validation.result @@ -0,0 +1,2 @@ +# Fetching and validating /metrics output format +OK: format validation passed diff --git a/mysql-test/suite/prometheus_exporter/r/global_variables.result b/mysql-test/suite/prometheus_exporter/r/global_variables.result new file mode 100644 index 000000000000..4c32a6b05ffe --- /dev/null +++ b/mysql-test/suite/prometheus_exporter/r/global_variables.result @@ -0,0 +1,6 @@ +# Verify mysql_global_variables_ metrics are present +# TYPE mysql_global_variables_max_connections gauge +# Verify a buffer pool size variable is exported +# TYPE mysql_global_variables_innodb_buffer_pool_size gauge +# Verify the value is numeric +mysql_global_variables_max_connections NUM diff --git a/mysql-test/suite/prometheus_exporter/r/innodb_metrics.result b/mysql-test/suite/prometheus_exporter/r/innodb_metrics.result new file mode 100644 index 000000000000..d1f4c7b99b09 --- /dev/null +++ b/mysql-test/suite/prometheus_exporter/r/innodb_metrics.result @@ -0,0 +1,8 @@ +# Verify InnoDB metrics are present +# TYPE mysql_innodb_metrics_lock_deadlocks counter +# TYPE mysql_innodb_metrics_lock_deadlock_false_positives counter +# TYPE mysql_innodb_metrics_lock_deadlock_rounds counter +# Verify a known gauge type metric exists (buffer_pool_reads is status_counter in InnoDB) +# TYPE mysql_innodb_metrics_buffer_pool_reads gauge +# Verify a known gauge type metric exists (buffer_pool_size is 'value' type in InnoDB) +# TYPE mysql_innodb_metrics_buffer_pool_size gauge diff --git a/mysql-test/suite/prometheus_exporter/r/metrics_endpoint.result b/mysql-test/suite/prometheus_exporter/r/metrics_endpoint.result new file mode 100644 index 000000000000..f4c0d9404569 --- /dev/null +++ b/mysql-test/suite/prometheus_exporter/r/metrics_endpoint.result @@ -0,0 +1,19 @@ +SHOW VARIABLES LIKE 'prometheus_exporter_enabled'; +Variable_name Value +prometheus_exporter_enabled ON +# Verify SHOW GLOBAL STATUS metrics +# TYPE mysql_global_status_threads_connected gauge +# Verify SHOW GLOBAL VARIABLES metrics +# TYPE mysql_global_variables_max_connections gauge +# Verify INNODB_METRICS metrics +# TYPE mysql_innodb_metrics_lock_deadlocks counter +# Verify binlog metrics (binary logging is on by default) +# TYPE mysql_binlog_file_count gauge +# Verify metric value line is present and numeric +mysql_global_status_threads_connected NUM +# Test 404 for unknown paths +404 +# Verify scrape counter incremented +SHOW STATUS LIKE 'Prometheus_exporter_requests_total'; +Variable_name Value +Prometheus_exporter_requests_total NUM diff --git a/mysql-test/suite/prometheus_exporter/r/replica_channels.result b/mysql-test/suite/prometheus_exporter/r/replica_channels.result new file mode 100644 index 000000000000..1dccceae2962 --- /dev/null +++ b/mysql-test/suite/prometheus_exporter/r/replica_channels.result @@ -0,0 +1,13 @@ +include/rpl/init.inc [topology=1->2,3->2] +include/rpl/start_replica.inc [FOR CHANNEL 'channel_1'] +include/rpl/start_replica.inc [FOR CHANNEL 'channel_3'] +# Verify multi-channel replica metrics carry a channel label +1 +2 +1 +1 +mysql_replica_io_running{channel="channel_1"} 1 +mysql_replica_io_running{channel="channel_3"} 1 +mysql_replica_sql_running{channel="channel_1"} 1 +mysql_replica_sql_running{channel="channel_3"} 1 +include/rpl/deinit.inc diff --git a/mysql-test/suite/prometheus_exporter/r/replica_null_lag.result b/mysql-test/suite/prometheus_exporter/r/replica_null_lag.result new file mode 100644 index 000000000000..dd1ae5038d44 --- /dev/null +++ b/mysql-test/suite/prometheus_exporter/r/replica_null_lag.result @@ -0,0 +1,9 @@ +include/rpl/init_source_replica.inc +[connection master] +include/rpl/stop_replica.inc +# Stopped replicas still expose lag as NaN instead of dropping the series +1 +mysql_replica_seconds_behind_source NaN +mysql_replica_io_running 0 +mysql_replica_sql_running 0 +include/rpl/deinit.inc diff --git a/mysql-test/suite/prometheus_exporter/r/replica_status.result b/mysql-test/suite/prometheus_exporter/r/replica_status.result new file mode 100644 index 000000000000..d3e029957605 --- /dev/null +++ b/mysql-test/suite/prometheus_exporter/r/replica_status.result @@ -0,0 +1,4 @@ +# On a non-replica server, no mysql_replica_ metrics should appear +0 +# But other metrics should still be present +# TYPE mysql_global_status_uptime gauge diff --git a/mysql-test/suite/prometheus_exporter/r/scrape_counter.result b/mysql-test/suite/prometheus_exporter/r/scrape_counter.result new file mode 100644 index 000000000000..615d97238202 --- /dev/null +++ b/mysql-test/suite/prometheus_exporter/r/scrape_counter.result @@ -0,0 +1,23 @@ +# Capture baseline counter +SELECT VARIABLE_VALUE INTO @before FROM performance_schema.global_status +WHERE VARIABLE_NAME = 'Prometheus_exporter_requests_total'; +# Issue exactly 3 scrapes +# Capture counter after scrapes +SELECT VARIABLE_VALUE INTO @after FROM performance_schema.global_status +WHERE VARIABLE_NAME = 'Prometheus_exporter_requests_total'; +# Assert delta is exactly 3 +SELECT (@after - @before) = 3 AS counter_delta_is_three; +counter_delta_is_three +1 +# Also verify scrape_duration_microseconds is now > 0 +SELECT VARIABLE_VALUE > 0 AS scrape_duration_is_positive +FROM performance_schema.global_status +WHERE VARIABLE_NAME = 'Prometheus_exporter_scrape_duration_microseconds'; +scrape_duration_is_positive +1 +# And verify errors_total is still 0 (no errors on successful scrapes) +SELECT VARIABLE_VALUE = 0 AS errors_total_is_zero +FROM performance_schema.global_status +WHERE VARIABLE_NAME = 'Prometheus_exporter_errors_total'; +errors_total_is_zero +1 diff --git a/mysql-test/suite/prometheus_exporter/r/scrape_error.result b/mysql-test/suite/prometheus_exporter/r/scrape_error.result new file mode 100644 index 000000000000..a699e78a1de8 --- /dev/null +++ b/mysql-test/suite/prometheus_exporter/r/scrape_error.result @@ -0,0 +1,7 @@ +# Failed scrapes return HTTP 500 instead of a false-success 200 +500 +# Failed to set security context (user missing or lacks privileges?) +# Failed scrapes increment the exporter error counter +SHOW STATUS LIKE 'Prometheus_exporter_errors_total'; +Variable_name Value +Prometheus_exporter_errors_total NUM diff --git a/mysql-test/suite/prometheus_exporter/t/basic.test b/mysql-test/suite/prometheus_exporter/t/basic.test new file mode 100644 index 000000000000..82f8ccb29464 --- /dev/null +++ b/mysql-test/suite/prometheus_exporter/t/basic.test @@ -0,0 +1,34 @@ +# ============================================================================= +# basic.test -- Prometheus Exporter Plugin: Install/Uninstall Lifecycle +# +# Verifies: +# - Plugin can be dynamically installed and uninstalled +# - System variables (enabled, port, bind_address) are registered +# - Status variables (requests_total, errors_total, scrape_duration) exist +# - Default values are correct (enabled=OFF, port=9104, bind=0.0.0.0) +# ============================================================================= + +--source include/not_windows.inc + +# Check that plugin is available +disable_query_log; +if (`SELECT @@have_dynamic_loading != 'YES'`) { + --skip prometheus_exporter plugin requires dynamic loading +} +if (!$PROMETHEUS_EXPORTER_PLUGIN) { + --skip prometheus_exporter plugin requires the environment variable \$PROMETHEUS_EXPORTER_PLUGIN to be set (normally done by mtr) +} +enable_query_log; + +--echo # Install prometheus_exporter plugin +--replace_result $PROMETHEUS_EXPORTER_PLUGIN PROMETHEUS_EXPORTER_PLUGIN +eval INSTALL PLUGIN prometheus_exporter SONAME '$PROMETHEUS_EXPORTER_PLUGIN'; + +--echo # Verify system variables exist with correct defaults +SHOW VARIABLES LIKE 'prometheus_exporter%'; + +--echo # Verify status variables exist (all zero when disabled) +SHOW STATUS LIKE 'Prometheus_exporter%'; + +--echo # Uninstall plugin +UNINSTALL PLUGIN prometheus_exporter; diff --git a/mysql-test/suite/prometheus_exporter/t/binlog-master.opt b/mysql-test/suite/prometheus_exporter/t/binlog-master.opt new file mode 100644 index 000000000000..c0c351bda379 --- /dev/null +++ b/mysql-test/suite/prometheus_exporter/t/binlog-master.opt @@ -0,0 +1 @@ +$PROMETHEUS_EXPORTER_PLUGIN_OPT $PROMETHEUS_EXPORTER_PLUGIN_LOAD --loose-prometheus-exporter-enabled=ON --loose-prometheus-exporter-port=19108 --loose-prometheus-exporter-bind-address=127.0.0.1 diff --git a/mysql-test/suite/prometheus_exporter/t/binlog.test b/mysql-test/suite/prometheus_exporter/t/binlog.test new file mode 100644 index 000000000000..8357027da220 --- /dev/null +++ b/mysql-test/suite/prometheus_exporter/t/binlog.test @@ -0,0 +1,12 @@ +--source include/not_windows.inc +--source suite/prometheus_exporter/inc/have_prometheus_exporter_plugin.inc + +--echo # Verify binlog metrics are present +--exec curl -s http://127.0.0.1:19108/metrics | grep "^# TYPE mysql_binlog_file_count gauge" | head -1 + +--echo # Verify binlog size metric +--exec curl -s http://127.0.0.1:19108/metrics | grep "^# TYPE mysql_binlog_size_bytes_total gauge" | head -1 + +--echo # Verify values are numeric +--replace_regex /[0-9]+/NUM/ +--exec curl -s http://127.0.0.1:19108/metrics | grep "^mysql_binlog_file_count " | head -1 diff --git a/mysql-test/suite/prometheus_exporter/t/format_validation-master.opt b/mysql-test/suite/prometheus_exporter/t/format_validation-master.opt new file mode 100644 index 000000000000..ed75369b2fc4 --- /dev/null +++ b/mysql-test/suite/prometheus_exporter/t/format_validation-master.opt @@ -0,0 +1 @@ +$PROMETHEUS_EXPORTER_PLUGIN_OPT $PROMETHEUS_EXPORTER_PLUGIN_LOAD --loose-prometheus-exporter-enabled=ON --loose-prometheus-exporter-port=19109 --loose-prometheus-exporter-bind-address=127.0.0.1 diff --git a/mysql-test/suite/prometheus_exporter/t/format_validation.test b/mysql-test/suite/prometheus_exporter/t/format_validation.test new file mode 100644 index 000000000000..64b4e433c819 --- /dev/null +++ b/mysql-test/suite/prometheus_exporter/t/format_validation.test @@ -0,0 +1,92 @@ +# ============================================================================= +# format_validation.test -- Validates Prometheus exposition format correctness +# +# Uses a perl block to fetch /metrics and validate: +# - Every # TYPE line has a valid type (counter, gauge, untyped) +# - Every # TYPE line is followed by a metric line with matching name +# - Metric lines may optionally include Prometheus labels +# - Metric names match [a-z_][a-z0-9_]* +# - Every metric value is numeric or NaN/+Inf/-Inf per Prometheus spec +# ============================================================================= + +--source include/not_windows.inc +--source suite/prometheus_exporter/inc/have_prometheus_exporter_plugin.inc + +--echo # Fetching and validating /metrics output format + +--perl +use strict; +use warnings; + +my $output = `curl -s http://127.0.0.1:19109/metrics`; +my @lines = split /\n/, $output; +my $errors = 0; +my $metrics_count = 0; +my $expect_metric_name = undef; +my %seen_types; + +for (my $i = 0; $i < scalar @lines; $i++) { + my $line = $lines[$i]; + + # Skip empty lines + next if $line =~ /^\s*$/; + + # Check # TYPE lines + if ($line =~ /^# TYPE /) { + if (defined $expect_metric_name) { + print "FORMAT ERROR: missing metric line for TYPE '$expect_metric_name'\n"; + $errors++; + } + if ($line =~ /^# TYPE ([a-z_][a-z0-9_]*) (counter|gauge|untyped)$/) { + if ($seen_types{$1}++) { + print "FORMAT ERROR: duplicate TYPE line for '$1'\n"; + $errors++; + } + $expect_metric_name = $1; + } else { + print "FORMAT ERROR: invalid TYPE line: $line\n"; + $errors++; + $expect_metric_name = undef; + } + next; + } + + # Skip other comment lines + next if $line =~ /^#/; + + # Metric value line + if ($line =~ /^([a-z_][a-z0-9_]*)(\{(?:[a-z_][a-z0-9_]*="(?:[^"\\]|\\["\\n])*"(?:,[a-z_][a-z0-9_]*="(?:[^"\\]|\\["\\n])*")*)\})? (.+)$/) { + my ($name, $value) = ($1, $3); + $metrics_count++; + + # Check name matches expected from TYPE line + if (defined $expect_metric_name && $name ne $expect_metric_name) { + print "FORMAT ERROR: expected metric '$expect_metric_name' but got '$name'\n"; + $errors++; + } + $expect_metric_name = undef; + + # Check value is numeric (Prometheus exposition format: numeric or NaN/Inf) + unless ($value =~ /^(-?[0-9]+(\.[0-9]+)?([eE][+-]?[0-9]+)?|NaN|\+Inf|-Inf)$/) { + print "FORMAT ERROR: non-numeric value '$value' for metric '$name'\n"; + $errors++; + } + } else { + print "FORMAT ERROR: unrecognized line: $line\n"; + $errors++; + } +} + +if (defined $expect_metric_name) { + print "FORMAT ERROR: missing metric line for TYPE '$expect_metric_name'\n"; + $errors++; +} + +if ($errors == 0 && $metrics_count > 0) { + print "OK: format validation passed\n"; +} elsif ($metrics_count == 0) { + print "ERROR: no metrics found in output\n"; +} else { + print "FAIL: $errors format errors found in $metrics_count metrics\n"; +} +EOF diff --git a/mysql-test/suite/prometheus_exporter/t/global_variables-master.opt b/mysql-test/suite/prometheus_exporter/t/global_variables-master.opt new file mode 100644 index 000000000000..41abc640e457 --- /dev/null +++ b/mysql-test/suite/prometheus_exporter/t/global_variables-master.opt @@ -0,0 +1 @@ +$PROMETHEUS_EXPORTER_PLUGIN_OPT $PROMETHEUS_EXPORTER_PLUGIN_LOAD --loose-prometheus-exporter-enabled=ON --loose-prometheus-exporter-port=19105 --loose-prometheus-exporter-bind-address=127.0.0.1 diff --git a/mysql-test/suite/prometheus_exporter/t/global_variables.test b/mysql-test/suite/prometheus_exporter/t/global_variables.test new file mode 100644 index 000000000000..05987c58c8f3 --- /dev/null +++ b/mysql-test/suite/prometheus_exporter/t/global_variables.test @@ -0,0 +1,12 @@ +--source include/not_windows.inc +--source suite/prometheus_exporter/inc/have_prometheus_exporter_plugin.inc + +--echo # Verify mysql_global_variables_ metrics are present +--exec curl -s http://127.0.0.1:19105/metrics | grep "^# TYPE mysql_global_variables_max_connections gauge" | head -1 + +--echo # Verify a buffer pool size variable is exported +--exec curl -s http://127.0.0.1:19105/metrics | grep "^# TYPE mysql_global_variables_innodb_buffer_pool_size gauge" | head -1 + +--echo # Verify the value is numeric +--replace_regex /[0-9]+/NUM/ +--exec curl -s http://127.0.0.1:19105/metrics | grep "^mysql_global_variables_max_connections " | head -1 diff --git a/mysql-test/suite/prometheus_exporter/t/innodb_metrics-master.opt b/mysql-test/suite/prometheus_exporter/t/innodb_metrics-master.opt new file mode 100644 index 000000000000..aeb4d1ef3cf9 --- /dev/null +++ b/mysql-test/suite/prometheus_exporter/t/innodb_metrics-master.opt @@ -0,0 +1 @@ +$PROMETHEUS_EXPORTER_PLUGIN_OPT $PROMETHEUS_EXPORTER_PLUGIN_LOAD --loose-prometheus-exporter-enabled=ON --loose-prometheus-exporter-port=19106 --loose-prometheus-exporter-bind-address=127.0.0.1 diff --git a/mysql-test/suite/prometheus_exporter/t/innodb_metrics.test b/mysql-test/suite/prometheus_exporter/t/innodb_metrics.test new file mode 100644 index 000000000000..94e3d44686c4 --- /dev/null +++ b/mysql-test/suite/prometheus_exporter/t/innodb_metrics.test @@ -0,0 +1,11 @@ +--source include/not_windows.inc +--source suite/prometheus_exporter/inc/have_prometheus_exporter_plugin.inc + +--echo # Verify InnoDB metrics are present +--exec curl -s http://127.0.0.1:19106/metrics | grep "^# TYPE mysql_innodb_metrics_" | head -3 + +--echo # Verify a known gauge type metric exists (buffer_pool_reads is status_counter in InnoDB) +--exec curl -s http://127.0.0.1:19106/metrics | grep "^# TYPE mysql_innodb_metrics_buffer_pool_reads " | head -1 + +--echo # Verify a known gauge type metric exists (buffer_pool_size is 'value' type in InnoDB) +--exec curl -s http://127.0.0.1:19106/metrics | grep "^# TYPE mysql_innodb_metrics_buffer_pool_size " | head -1 diff --git a/mysql-test/suite/prometheus_exporter/t/metrics_endpoint-master.opt b/mysql-test/suite/prometheus_exporter/t/metrics_endpoint-master.opt new file mode 100644 index 000000000000..d2e1d5f2e03a --- /dev/null +++ b/mysql-test/suite/prometheus_exporter/t/metrics_endpoint-master.opt @@ -0,0 +1 @@ +$PROMETHEUS_EXPORTER_PLUGIN_OPT $PROMETHEUS_EXPORTER_PLUGIN_LOAD --loose-prometheus-exporter-enabled=ON --loose-prometheus-exporter-port=19104 --loose-prometheus-exporter-bind-address=127.0.0.1 diff --git a/mysql-test/suite/prometheus_exporter/t/metrics_endpoint.test b/mysql-test/suite/prometheus_exporter/t/metrics_endpoint.test new file mode 100644 index 000000000000..db502ae2cefd --- /dev/null +++ b/mysql-test/suite/prometheus_exporter/t/metrics_endpoint.test @@ -0,0 +1,38 @@ +# ============================================================================= +# metrics_endpoint.test -- Prometheus Exporter: HTTP Endpoint & All Collectors +# +# Verifies: +# - HTTP endpoint serves Prometheus text format +# - 4 of 5 collector prefixes appear in output (replica not testable without replica setup) +# - 404 returned for unknown paths +# - Scrape counter increments +# ============================================================================= + +--source include/not_windows.inc +--source suite/prometheus_exporter/inc/have_prometheus_exporter_plugin.inc + +# Verify plugin is loaded and enabled +SHOW VARIABLES LIKE 'prometheus_exporter_enabled'; + +--echo # Verify SHOW GLOBAL STATUS metrics +--exec curl -s http://127.0.0.1:19104/metrics | grep "^# TYPE mysql_global_status_threads_connected" | head -1 + +--echo # Verify SHOW GLOBAL VARIABLES metrics +--exec curl -s http://127.0.0.1:19104/metrics | grep "^# TYPE mysql_global_variables_max_connections" | head -1 + +--echo # Verify INNODB_METRICS metrics +--exec curl -s http://127.0.0.1:19104/metrics | grep "^# TYPE mysql_innodb_metrics_" | head -1 + +--echo # Verify binlog metrics (binary logging is on by default) +--exec curl -s http://127.0.0.1:19104/metrics | grep "^# TYPE mysql_binlog_file_count" | head -1 + +--echo # Verify metric value line is present and numeric +--replace_regex /[0-9]+/NUM/ +--exec curl -s http://127.0.0.1:19104/metrics | grep "^mysql_global_status_threads_connected " | head -1 + +--echo # Test 404 for unknown paths +--exec curl -s -o /dev/null -w "%{http_code}\n" http://127.0.0.1:19104/notfound + +--echo # Verify scrape counter incremented +--replace_regex /[0-9]+/NUM/ +SHOW STATUS LIKE 'Prometheus_exporter_requests_total'; diff --git a/mysql-test/suite/prometheus_exporter/t/replica_channels-slave.opt b/mysql-test/suite/prometheus_exporter/t/replica_channels-slave.opt new file mode 100644 index 000000000000..92c9b81b9998 --- /dev/null +++ b/mysql-test/suite/prometheus_exporter/t/replica_channels-slave.opt @@ -0,0 +1 @@ +$PROMETHEUS_EXPORTER_PLUGIN_OPT $PROMETHEUS_EXPORTER_PLUGIN_LOAD --loose-prometheus-exporter-enabled=ON --loose-prometheus-exporter-port=19111 --loose-prometheus-exporter-bind-address=127.0.0.1 diff --git a/mysql-test/suite/prometheus_exporter/t/replica_channels.cnf b/mysql-test/suite/prometheus_exporter/t/replica_channels.cnf new file mode 100644 index 000000000000..c03113d73c08 --- /dev/null +++ b/mysql-test/suite/prometheus_exporter/t/replica_channels.cnf @@ -0,0 +1,11 @@ +!include ../../rpl_gtid/my.cnf + +[mysqld.1] + +[mysqld.2] + +[mysqld.3] + +[ENV] +SERVER_MYPORT_3= @mysqld.3.port +SERVER_MYSOCK_3= @mysqld.3.socket diff --git a/mysql-test/suite/prometheus_exporter/t/replica_channels.test b/mysql-test/suite/prometheus_exporter/t/replica_channels.test new file mode 100644 index 000000000000..b430100f3d45 --- /dev/null +++ b/mysql-test/suite/prometheus_exporter/t/replica_channels.test @@ -0,0 +1,41 @@ +--source include/not_windows.inc +--source suite/prometheus_exporter/inc/have_prometheus_exporter_plugin.inc + +--disable_result_log +--disable_query_log +--disable_warnings +--let $rpl_topology= 1->2,3->2 +--let $rpl_multi_source= 1 +--let $rpl_skip_start_slave= 1 +--source include/rpl/init.inc + +--connection server_2 +--let $rpl_channel_name= 'channel_1' +--source include/rpl/start_replica.inc +--let $rpl_channel_name= 'channel_3' +--source include/rpl/start_replica.inc +--enable_warnings + +--let $wait_condition= SELECT COUNT(*) = 2 FROM performance_schema.replication_connection_status WHERE CHANNEL_NAME IN ('channel_1', 'channel_3') AND SERVICE_STATE='ON' +--source include/wait_condition_or_abort.inc +--let $wait_condition= SELECT COUNT(*) = 2 FROM performance_schema.replication_applier_status WHERE CHANNEL_NAME IN ('channel_1', 'channel_3') AND SERVICE_STATE='ON' +--source include/wait_condition_or_abort.inc +--enable_result_log +--enable_query_log + +--echo # Verify multi-channel replica metrics carry a channel label +--exec sh -c 'curl -s http://127.0.0.1:19111/metrics > "$MYSQLTEST_VARDIR/tmp/replica_channels.metrics"' +--exec grep -c '^# TYPE mysql_replica_read_source_log_pos gauge$' "$MYSQLTEST_VARDIR/tmp/replica_channels.metrics" || true +--exec grep -c '^mysql_replica_read_source_log_pos{channel="' "$MYSQLTEST_VARDIR/tmp/replica_channels.metrics" || true +--exec grep -c '^mysql_replica_read_source_log_pos{channel="channel_1"} ' "$MYSQLTEST_VARDIR/tmp/replica_channels.metrics" || true +--exec grep -c '^mysql_replica_read_source_log_pos{channel="channel_3"} ' "$MYSQLTEST_VARDIR/tmp/replica_channels.metrics" || true +--exec grep '^mysql_replica_io_running{channel="channel_1"} 1$' "$MYSQLTEST_VARDIR/tmp/replica_channels.metrics" | head -1 +--exec grep '^mysql_replica_io_running{channel="channel_3"} 1$' "$MYSQLTEST_VARDIR/tmp/replica_channels.metrics" | head -1 +--exec grep '^mysql_replica_sql_running{channel="channel_1"} 1$' "$MYSQLTEST_VARDIR/tmp/replica_channels.metrics" | head -1 +--exec grep '^mysql_replica_sql_running{channel="channel_3"} 1$' "$MYSQLTEST_VARDIR/tmp/replica_channels.metrics" | head -1 +--remove_file $MYSQLTEST_VARDIR/tmp/replica_channels.metrics + +--disable_result_log +--disable_query_log +--let $rpl_skip_sync= 1 +--source include/rpl/deinit.inc diff --git a/mysql-test/suite/prometheus_exporter/t/replica_null_lag-slave.opt b/mysql-test/suite/prometheus_exporter/t/replica_null_lag-slave.opt new file mode 100644 index 000000000000..96a0ff0f13c7 --- /dev/null +++ b/mysql-test/suite/prometheus_exporter/t/replica_null_lag-slave.opt @@ -0,0 +1 @@ +$PROMETHEUS_EXPORTER_PLUGIN_OPT $PROMETHEUS_EXPORTER_PLUGIN_LOAD --loose-prometheus-exporter-enabled=ON --loose-prometheus-exporter-port=19113 --loose-prometheus-exporter-bind-address=127.0.0.1 diff --git a/mysql-test/suite/prometheus_exporter/t/replica_null_lag.cnf b/mysql-test/suite/prometheus_exporter/t/replica_null_lag.cnf new file mode 100644 index 000000000000..11865c8d8bcd --- /dev/null +++ b/mysql-test/suite/prometheus_exporter/t/replica_null_lag.cnf @@ -0,0 +1,5 @@ +!include ../../rpl_gtid/my.cnf + +[mysqld.1] + +[mysqld.2] diff --git a/mysql-test/suite/prometheus_exporter/t/replica_null_lag.test b/mysql-test/suite/prometheus_exporter/t/replica_null_lag.test new file mode 100644 index 000000000000..725d13a2954d --- /dev/null +++ b/mysql-test/suite/prometheus_exporter/t/replica_null_lag.test @@ -0,0 +1,25 @@ +--source include/not_windows.inc +--source suite/prometheus_exporter/inc/have_prometheus_exporter_plugin.inc + +--disable_query_log +--disable_result_log +--source include/rpl/init_source_replica.inc + +--connection slave +--source include/rpl/stop_replica.inc + +--enable_result_log +--enable_query_log + +--echo # Stopped replicas still expose lag as NaN instead of dropping the series +--exec sh -c 'curl -s http://127.0.0.1:19113/metrics > "$MYSQLTEST_VARDIR/tmp/replica_null_lag.metrics"' +--exec grep -c '^# TYPE mysql_replica_seconds_behind_source gauge$' "$MYSQLTEST_VARDIR/tmp/replica_null_lag.metrics" +--exec grep '^mysql_replica_seconds_behind_source NaN$' "$MYSQLTEST_VARDIR/tmp/replica_null_lag.metrics" | head -1 +--exec grep '^mysql_replica_io_running 0$' "$MYSQLTEST_VARDIR/tmp/replica_null_lag.metrics" | head -1 +--exec grep '^mysql_replica_sql_running 0$' "$MYSQLTEST_VARDIR/tmp/replica_null_lag.metrics" | head -1 +--remove_file $MYSQLTEST_VARDIR/tmp/replica_null_lag.metrics + +--disable_query_log +--disable_result_log +--let $rpl_skip_sync= 1 +--source include/rpl/deinit.inc diff --git a/mysql-test/suite/prometheus_exporter/t/replica_status-master.opt b/mysql-test/suite/prometheus_exporter/t/replica_status-master.opt new file mode 100644 index 000000000000..dd04b6908cd0 --- /dev/null +++ b/mysql-test/suite/prometheus_exporter/t/replica_status-master.opt @@ -0,0 +1 @@ +$PROMETHEUS_EXPORTER_PLUGIN_OPT $PROMETHEUS_EXPORTER_PLUGIN_LOAD --loose-prometheus-exporter-enabled=ON --loose-prometheus-exporter-port=19107 --loose-prometheus-exporter-bind-address=127.0.0.1 diff --git a/mysql-test/suite/prometheus_exporter/t/replica_status.test b/mysql-test/suite/prometheus_exporter/t/replica_status.test new file mode 100644 index 000000000000..afd4fe5f9f10 --- /dev/null +++ b/mysql-test/suite/prometheus_exporter/t/replica_status.test @@ -0,0 +1,8 @@ +--source include/not_windows.inc +--source suite/prometheus_exporter/inc/have_prometheus_exporter_plugin.inc + +--echo # On a non-replica server, no mysql_replica_ metrics should appear +--exec curl -s http://127.0.0.1:19107/metrics | grep -c "mysql_replica_" || true + +--echo # But other metrics should still be present +--exec curl -s http://127.0.0.1:19107/metrics | grep "^# TYPE mysql_global_status_uptime" | head -1 diff --git a/mysql-test/suite/prometheus_exporter/t/scrape_counter-master.opt b/mysql-test/suite/prometheus_exporter/t/scrape_counter-master.opt new file mode 100644 index 000000000000..3772b212da81 --- /dev/null +++ b/mysql-test/suite/prometheus_exporter/t/scrape_counter-master.opt @@ -0,0 +1 @@ +$PROMETHEUS_EXPORTER_PLUGIN_OPT $PROMETHEUS_EXPORTER_PLUGIN_LOAD --loose-prometheus-exporter-enabled=ON --loose-prometheus-exporter-port=19110 --loose-prometheus-exporter-bind-address=127.0.0.1 diff --git a/mysql-test/suite/prometheus_exporter/t/scrape_counter.test b/mysql-test/suite/prometheus_exporter/t/scrape_counter.test new file mode 100644 index 000000000000..4a91ff494f13 --- /dev/null +++ b/mysql-test/suite/prometheus_exporter/t/scrape_counter.test @@ -0,0 +1,36 @@ +# ============================================================================= +# scrape_counter.test -- Verify the requests_total counter actually increments +# +# Captures the counter before and after a specific number of curl requests +# and asserts the exact delta. Catches regressions where the counter is +# silently not incremented. +# ============================================================================= + +--source include/not_windows.inc +--source suite/prometheus_exporter/inc/have_prometheus_exporter_plugin.inc + +--echo # Capture baseline counter +SELECT VARIABLE_VALUE INTO @before FROM performance_schema.global_status + WHERE VARIABLE_NAME = 'Prometheus_exporter_requests_total'; + +--echo # Issue exactly 3 scrapes +--exec curl -s http://127.0.0.1:19110/metrics > /dev/null +--exec curl -s http://127.0.0.1:19110/metrics > /dev/null +--exec curl -s http://127.0.0.1:19110/metrics > /dev/null + +--echo # Capture counter after scrapes +SELECT VARIABLE_VALUE INTO @after FROM performance_schema.global_status + WHERE VARIABLE_NAME = 'Prometheus_exporter_requests_total'; + +--echo # Assert delta is exactly 3 +SELECT (@after - @before) = 3 AS counter_delta_is_three; + +--echo # Also verify scrape_duration_microseconds is now > 0 +SELECT VARIABLE_VALUE > 0 AS scrape_duration_is_positive +FROM performance_schema.global_status +WHERE VARIABLE_NAME = 'Prometheus_exporter_scrape_duration_microseconds'; + +--echo # And verify errors_total is still 0 (no errors on successful scrapes) +SELECT VARIABLE_VALUE = 0 AS errors_total_is_zero +FROM performance_schema.global_status +WHERE VARIABLE_NAME = 'Prometheus_exporter_errors_total'; diff --git a/mysql-test/suite/prometheus_exporter/t/scrape_error-master.opt b/mysql-test/suite/prometheus_exporter/t/scrape_error-master.opt new file mode 100644 index 000000000000..24ee80ed2ffa --- /dev/null +++ b/mysql-test/suite/prometheus_exporter/t/scrape_error-master.opt @@ -0,0 +1 @@ +$PROMETHEUS_EXPORTER_PLUGIN_OPT $PROMETHEUS_EXPORTER_PLUGIN_LOAD --loose-prometheus-exporter-enabled=ON --loose-prometheus-exporter-port=19112 --loose-prometheus-exporter-bind-address=127.0.0.1 --loose-prometheus-exporter-security-user=missing_prometheus_exporter_user diff --git a/mysql-test/suite/prometheus_exporter/t/scrape_error.test b/mysql-test/suite/prometheus_exporter/t/scrape_error.test new file mode 100644 index 000000000000..51c47e0239be --- /dev/null +++ b/mysql-test/suite/prometheus_exporter/t/scrape_error.test @@ -0,0 +1,11 @@ +--source include/not_windows.inc +--source suite/prometheus_exporter/inc/have_prometheus_exporter_plugin.inc + +--echo # Failed scrapes return HTTP 500 instead of a false-success 200 +--exec curl -s -o "$MYSQLTEST_VARDIR/tmp/scrape_error.body" -w "%{http_code}\n" http://127.0.0.1:19112/metrics +--exec grep '^# Failed to set security context (user missing or lacks privileges?)$' "$MYSQLTEST_VARDIR/tmp/scrape_error.body" | head -1 +--remove_file $MYSQLTEST_VARDIR/tmp/scrape_error.body + +--echo # Failed scrapes increment the exporter error counter +--replace_regex /[0-9]+/NUM/ +SHOW STATUS LIKE 'Prometheus_exporter_errors_total'; diff --git a/plugin/prometheus_exporter/CMakeLists.txt b/plugin/prometheus_exporter/CMakeLists.txt new file mode 100644 index 000000000000..47552299742c --- /dev/null +++ b/plugin/prometheus_exporter/CMakeLists.txt @@ -0,0 +1,31 @@ +# Copyright (c) 2025, Oracle and/or its affiliates. +# Copyright (c) 2026 VillageSQL Contributors +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License, version 2.0, +# as published by the Free Software Foundation. +# +# This program is designed to work with certain software (including +# but not limited to OpenSSL) that is licensed under separate terms, +# as designated in a particular file or component or in included license +# documentation. The authors of MySQL hereby grant you an additional +# permission to link the program and your derivative works with the +# separately licensed software that they have either included with +# the program or referenced in the documentation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License, version 2.0, for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA + +IF(LINUX) + MYSQL_ADD_PLUGIN(prometheus_exporter + prometheus_exporter.cc + MODULE_ONLY + MODULE_OUTPUT_NAME "prometheus_exporter" + ) +ENDIF() diff --git a/plugin/prometheus_exporter/README.md b/plugin/prometheus_exporter/README.md new file mode 100644 index 000000000000..fca28985c58e --- /dev/null +++ b/plugin/prometheus_exporter/README.md @@ -0,0 +1,178 @@ +# Prometheus Exporter Plugin + +An embedded Prometheus metrics exporter for VillageSQL/MySQL. Eliminates the +need for an external `mysqld_exporter` sidecar by serving metrics directly +from within the server process. + +## Architecture + +The plugin is a MySQL daemon plugin that spawns a single background thread +to serve HTTP. When Prometheus scrapes `/metrics`, the plugin opens an +internal `srv_session` (no network connection -- pure in-process function +calls), executes SQL queries against the server, formats the results in +Prometheus text exposition format, and returns them over HTTP. + +``` +┌─────────────────────────────────────────────────────┐ +│ VillageSQL Server │ +│ │ +│ ┌──────────────────────────────────────────────┐ │ +│ │ prometheus_exporter plugin │ │ +│ │ │ │ +│ │ ┌──────────────┐ ┌────────────────────┐ │ │ +│ │ │ HTTP Listener │ │ collect_metrics() │ │ │ +│ │ │ (poll loop) │───>│ │ │ │ +│ │ │:9104/metrics │ │ srv_session_open()│ │ │ +│ │ └──────────────┘ │ │ │ │ │ +│ │ │ ┌─────▼─────────┐ │ │ │ +│ │ │ │ SHOW GLOBAL │ │ │ │ +│ │ │ │ STATUS │ │ │ │ +│ │ │ ├───────────────┤ │ │ │ +│ │ │ │ SHOW GLOBAL │ │ │ │ +│ │ │ │ VARIABLES │ │ │ │ +│ │ │ ├───────────────┤ │ │ │ +│ │ │ │ INNODB_METRICS│ │ │ │ +│ │ │ ├───────────────┤ │ │ │ +│ │ │ │ SHOW REPLICA │ │ │ │ +│ │ │ │ STATUS │ │ │ │ +│ │ │ ├───────────────┤ │ │ │ +│ │ │ │ SHOW BINARY │ │ │ │ +│ │ │ │ LOGS │ │ │ │ +│ │ │ └───────────────┘ │ │ │ +│ │ └────────────────────┘ │ │ +│ └──────────────────────────────────────────────┘ │ +└─────────────────────────────────────────────────────┘ + ▲ + │ HTTP GET /metrics (every 15-60s) + │ + ┌────┴─────┐ + │Prometheus│ + │ Server │ + └──────────┘ +``` + +Key design choice: the plugin executes standard SQL queries via the +`srv_session` service API rather than accessing internal server structs +directly. This makes it resilient to MySQL version changes during rebases. + +## Configuration + +The plugin is disabled by default. All variables are read-only (require +server restart to change). Future versions may support dynamic changes to +port and bind address without restart if there is sufficient interest. + +| Variable | Type | Default | Description | +|----------|------|---------|-------------| +| `prometheus_exporter_enabled` | BOOL | OFF | Enable the HTTP metrics endpoint | +| `prometheus_exporter_port` | UINT | 9104 | TCP port to listen on (1024-65535) | +| `prometheus_exporter_bind_address` | STRING | 127.0.0.1 | Numeric IPv4 address to bind to | +| `prometheus_exporter_security_user` | STRING | root | Internal user name used for collector sessions | + +## Usage + +### Load at server startup (recommended) + +```ini +# my.cnf +[mysqld] +plugin-load=prometheus_exporter=prometheus_exporter.so +prometheus-exporter-enabled=ON +prometheus-exporter-port=9104 +prometheus-exporter-bind-address=127.0.0.1 +``` + +Use a numeric IPv4 address for `prometheus_exporter_bind_address`. Hostnames +such as `localhost` are not accepted by the current listener implementation. + +### Load at runtime + +```sql +INSTALL PLUGIN prometheus_exporter SONAME 'prometheus_exporter.so'; +``` + +Runtime installation is useful as a loadability smoke test. The HTTP endpoint +still requires the read-only startup variables to be set when the server boots, +so the recommended production path is to load the plugin from `my.cnf`. + +### Scrape + +```bash +curl http://127.0.0.1:9104/metrics +``` + +### Prometheus configuration + +```yaml +scrape_configs: + - job_name: 'villagesql' + static_configs: + - targets: ['your-db-host:9104'] +``` + +## Metric Namespaces + +| Prefix | Source | Type Logic | Metrics | +|--------|--------|-----------|---------| +| `mysql_global_status_` | `SHOW GLOBAL STATUS` | Known gauge list; rest `untyped` | ~400 server status counters | +| `mysql_global_variables_` | `SHOW GLOBAL VARIABLES` | All `gauge` | Numeric config values (max_connections, buffer sizes, etc.) | +| `mysql_innodb_metrics_` | `information_schema.INNODB_METRICS` | InnoDB TYPE column: `counter`->counter, others->gauge | ~200 detailed InnoDB internals | +| `mysql_replica_` | `SHOW REPLICA STATUS` | Per-field mapping, all `gauge` | Replication lag, IO/SQL thread state, log positions | +| `mysql_binlog_` | `SHOW BINARY LOGS` | All `gauge` | File count and total size | + +## Metric Type Classification + +**Global Status**: A static list of known gauge variables (Threads_connected, +Open_tables, Uptime, buffer pool pages, etc.) are typed as `gauge`. All +others are typed as `untyped` since without additional context it's +ambiguous whether they're monotonic counters or point-in-time values. + +**Global Variables**: All typed as `gauge` -- configuration values are +point-in-time snapshots. + +**InnoDB Metrics**: Uses the `TYPE` column from `INNODB_METRICS`: +- `counter` -> Prometheus `counter` +- `value`, `status_counter`, `set_owner`, `set_member` -> Prometheus `gauge` + +**Replica Status**: All fields typed as `gauge`. Boolean fields +(Replica_IO_Running, Replica_SQL_Running) are converted to 1/0. When +`SHOW REPLICA STATUS` returns multiple rows for named channels, samples are +labeled with `channel="..."` so multi-source replication remains representable. +The default unnamed channel remains unlabeled. `Seconds_Behind_Source=NULL` +is exported as `NaN`. + +**Binary Logs**: Both metrics (file_count, size_bytes_total) are `gauge`. + +## Plugin Status Variables + +The plugin exposes its own operational metrics via `SHOW GLOBAL STATUS`: + +| Variable | Description | +|----------|-------------| +| `Prometheus_exporter_requests_total` | Total number of /metrics scrapes served | +| `Prometheus_exporter_errors_total` | Total number of errors during scrapes | +| `Prometheus_exporter_scrape_duration_microseconds` | Duration of the last scrape in microseconds | + +## Limitations + +- **No TLS**: The HTTP endpoint is plain HTTP. Use `bind_address=127.0.0.1` + and a reverse proxy if TLS is needed. +- **No authentication**: Rely on bind address restriction and network-level + controls. Prometheus typically scrapes over a private network. +- **Single-threaded**: One scrape at a time. Concurrent requests queue on + the TCP backlog. This is fine for Prometheus's 15-60s scrape interval. +- **Linux only**: The plugin build is gated to Linux. The current + implementation uses Linux-specific APIs such as `eventfd()` and + `MSG_NOSIGNAL`. +- **Bind address format**: The current listener accepts numeric IPv4 + addresses only. Use `127.0.0.1`, not `localhost`. +- **Read-only variables**: Port and bind address require a server restart + to change (may become dynamic in future versions if demand warrants) + +## Verification Notes + +The Linux verification for this branch covers: + +- runtime `INSTALL PLUGIN` / `UNINSTALL PLUGIN` +- endpoint scrapes from a single server +- multi-channel replication with one replica connected to two sources +- Prometheus format validation with labeled replica metrics diff --git a/plugin/prometheus_exporter/prometheus_exporter.cc b/plugin/prometheus_exporter/prometheus_exporter.cc new file mode 100644 index 000000000000..10fd61b1b693 --- /dev/null +++ b/plugin/prometheus_exporter/prometheus_exporter.cc @@ -0,0 +1,1272 @@ +/** + * @file prometheus_exporter.cc + * @brief Embedded Prometheus metrics exporter plugin for VillageSQL/MySQL. + * + * This plugin serves Prometheus text exposition format metrics via an + * embedded HTTP server, eliminating the need for an external + * @c mysqld_exporter sidecar process. + * + * ## Architecture + * + * The plugin spawns a single background thread that runs a poll-based + * HTTP server on the configured port. When Prometheus scrapes @c /metrics, + * the plugin executes standard SQL queries via @c srv_session (no external + * network connection) and formats results in Prometheus text format. + * + * ## Supported Metric Sources + * + * - @c SHOW GLOBAL STATUS (mysql_global_status_*) + * - @c SHOW GLOBAL VARIABLES (mysql_global_variables_*) + * - @c INFORMATION_SCHEMA.INNODB_METRICS (mysql_innodb_metrics_*) + * - @c SHOW REPLICA STATUS (mysql_replica_*) with multi-channel labels + * - @c SHOW BINARY LOGS (mysql_binlog_*) + * + * ## Security Notes + * + * The HTTP endpoint has no authentication or TLS. Use a loopback bind address + * and restrict network access to the port. The plugin executes all queries + * through the server's internal session service using a configurable security + * context user. + * + * ## Platform Notes + * + * Requires Linux due to use of @c eventfd() and @c MSG_NOSIGNAL. + * + * @sa plugin/prometheus_exporter/README.md + */ + +/* Copyright (c) 2025, Oracle and/or its affiliates. + Copyright (c) 2026 VillageSQL Contributors + + This program is free software; you can redistribute it and/or modify + it under the terms of the GNU General Public License, version 2.0, + as published by the Free Software Foundation. + + This program is designed to work with certain software (including + but not limited to OpenSSL) that is licensed under separate terms, + as designated in a particular file or component or in included license + documentation. The authors of MySQL hereby grant you an additional + permission to link the program and your derivative works with the + separately licensed software that they have either included with + the program or referenced in the documentation. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License, version 2.0, for more details. + + You should have received a copy of the GNU General Public License + along with this program; if not, write to the Free Software + Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA */ + +#define LOG_COMPONENT_TAG "prometheus_exporter" + +#include +#include +#include +#include +#include + +#include +#include +#include + +#include "m_string.h" +#include "my_inttypes.h" +#include "my_sys.h" +#include "my_thread.h" +#include "mysql/strings/m_ctype.h" +#include "sql/sql_plugin.h" +#include "template_utils.h" + +#include +#include +#include +#include +#include + +#include + +#include +#include +#include +#include +#include +#include +#include + +static SERVICE_TYPE(registry) *reg_srv = nullptr; +SERVICE_TYPE(log_builtins) *log_bi = nullptr; +SERVICE_TYPE(log_builtins_string) *log_bs = nullptr; + +/** + * @name Plugin Configuration Variables + * @{ + */ + +/** @brief Enable the Prometheus metrics exporter HTTP endpoint. */ +static bool prom_enabled = false; + +/** + * @brief TCP port for the Prometheus exporter HTTP endpoint. + * + * Valid range: 1024-65535. Requires server restart to change. + */ +static unsigned int prom_port = 9104; + +/** + * @brief IPv4 address to bind the HTTP endpoint to. + * + * Must be a numeric IPv4 address (e.g. "127.0.0.1"). Hostnames are not + * accepted. Requires server restart to change. + */ +static char *prom_bind_address = nullptr; + +/** + * @brief MySQL account used for internal session-based metric queries. + * + * The account must exist on localhost and have sufficient privileges to + * query INNODB_METRICS, SHOW REPLICA STATUS, and related tables. + * Requires server restart to change. + */ +static char *prom_security_user = nullptr; + +/** @} */ + +static MYSQL_SYSVAR_BOOL(enabled, prom_enabled, + PLUGIN_VAR_READONLY | PLUGIN_VAR_OPCMDARG, + "Enable the Prometheus metrics exporter HTTP " + "endpoint. Default OFF.", + nullptr, nullptr, false); + +static MYSQL_SYSVAR_UINT(port, prom_port, + PLUGIN_VAR_READONLY | PLUGIN_VAR_OPCMDARG, + "TCP port for the Prometheus exporter HTTP " + "endpoint. Default 9104.", + nullptr, nullptr, 9104, 1024, 65535, 0); + +static MYSQL_SYSVAR_STR(bind_address, prom_bind_address, + PLUGIN_VAR_READONLY | PLUGIN_VAR_OPCMDARG | + PLUGIN_VAR_MEMALLOC, + "Bind address for the Prometheus exporter HTTP " + "endpoint. Default 127.0.0.1.", + nullptr, nullptr, "127.0.0.1"); + +static MYSQL_SYSVAR_STR(security_user, prom_security_user, + PLUGIN_VAR_READONLY | PLUGIN_VAR_OPCMDARG | + PLUGIN_VAR_MEMALLOC, + "MySQL account used internally to run the metric " + "collection queries. The account must exist on " + "localhost. Default: root. For reduced privilege, " + "use an account granted PROCESS, REPLICATION CLIENT, " + "and SELECT on information_schema.", + nullptr, nullptr, "root"); + +static SYS_VAR *prom_system_vars[] = { + MYSQL_SYSVAR(enabled), + MYSQL_SYSVAR(port), + MYSQL_SYSVAR(bind_address), + MYSQL_SYSVAR(security_user), + nullptr, +}; + +/** + * @name Plugin Operational Metrics + * @{ + */ + +/** + * @brief Total number of /metrics scrapes served. + * + * Exposed via SHOW GLOBAL STATUS as Prometheus_exporter_requests_total. + */ +static std::atomic g_requests_total{0}; + +/** + * @brief Total number of scrape errors encountered. + * + * Incremented when any collector query fails. Exposed via + * SHOW GLOBAL STATUS as Prometheus_exporter_errors_total. + */ +static std::atomic g_errors_total{0}; + +/** + * @brief Duration of the last scrape in microseconds. + * + * Exposed via SHOW GLOBAL STATUS as + * Prometheus_exporter_scrape_duration_microseconds. + */ +static std::atomic g_last_scrape_duration_us{0}; + +/** @} */ + +/** + * @brief Per-plugin-instance context for the HTTP listener thread. + * + * Contains all state needed to manage the background listener thread + * including file descriptors and shutdown coordination. + */ +struct PrometheusContext { + my_thread_handle listener_thread; + int listen_fd; + int wakeup_fd; + std::atomic shutdown_requested; + void *plugin_ref; + + PrometheusContext() + : listen_fd(-1), + wakeup_fd(-1), + shutdown_requested(false), + plugin_ref(nullptr) {} +}; + +static const char *gauge_variables[] = { + "Threads_connected", + "Threads_running", + "Threads_cached", + "Threads_created", + "Open_tables", + "Open_files", + "Open_streams", + "Open_table_definitions", + "Opened_tables", + "Innodb_buffer_pool_pages_data", + "Innodb_buffer_pool_pages_dirty", + "Innodb_buffer_pool_pages_free", + "Innodb_buffer_pool_pages_misc", + "Innodb_buffer_pool_pages_total", + "Innodb_buffer_pool_bytes_data", + "Innodb_buffer_pool_bytes_dirty", + "Innodb_page_size", + "Innodb_data_pending_reads", + "Innodb_data_pending_writes", + "Innodb_data_pending_fsyncs", + "Innodb_os_log_pending_writes", + "Innodb_os_log_pending_fsyncs", + "Innodb_row_lock_current_waits", + "Key_blocks_unused", + "Key_blocks_used", + "Key_blocks_not_flushed", + "Max_used_connections", + "Uptime", + "Uptime_since_flush_status", + nullptr, +}; + +static bool is_gauge(const char *name) { + for (const char **p = gauge_variables; *p != nullptr; ++p) { + if (strcasecmp(name, *p) == 0) return true; + } + return false; +} + +typedef const char *(*type_fn_t)(const char *name); + +struct MetricsCollectorCtx { + std::string *output; + std::string prefix; + type_fn_t type_fn; + std::string current_name; + std::string current_value; + int col_index; + bool error; +}; + +static const char *global_status_type(const char *name) { + return is_gauge(name) ? "gauge" : "untyped"; +} + +static int prom_start_result_metadata(void *, uint, uint, + const CHARSET_INFO *) { + return 0; +} + +static int prom_field_metadata(void *, struct st_send_field *, + const CHARSET_INFO *) { + return 0; +} + +static int prom_end_result_metadata(void *, uint, uint) { return 0; } + +static int prom_start_row(void *ctx) { + auto *mc = static_cast(ctx); + mc->col_index = 0; + mc->current_name.clear(); + mc->current_value.clear(); + return 0; +} + +static int prom_end_row(void *ctx) { + auto *mc = static_cast(ctx); + + if (mc->current_name.empty() || mc->current_value.empty()) return 0; + + // Try to parse as a number; skip non-numeric values (ON/OFF etc.) + char *end = nullptr; + strtod(mc->current_value.c_str(), &end); + if (end == mc->current_value.c_str() || *end != '\0') return 0; + + // Build the Prometheus metric name: prefix + lowercase(mysql_name) + std::string prom_name = mc->prefix; + for (const char *p = mc->current_name.c_str(); *p != '\0'; ++p) { + prom_name += static_cast(tolower(static_cast(*p))); + } + const char *type_str = mc->type_fn(mc->current_name.c_str()); + + *mc->output += "# TYPE "; + *mc->output += prom_name; + *mc->output += ' '; + *mc->output += type_str; + *mc->output += '\n'; + *mc->output += prom_name; + *mc->output += ' '; + *mc->output += mc->current_value; + *mc->output += '\n'; + + return 0; +} + +static void prom_abort_row(void *) {} + +static ulong prom_get_client_capabilities(void *) { return 0; } + +static int prom_get_null(void *) { return 0; } +static int prom_get_integer(void *, longlong) { return 0; } +static int prom_get_longlong(void *, longlong, uint) { return 0; } +static int prom_get_decimal(void *, const decimal_t *) { return 0; } +static int prom_get_double(void *, double, uint32) { return 0; } +static int prom_get_date(void *, const MYSQL_TIME *) { return 0; } +static int prom_get_time(void *, const MYSQL_TIME *, uint) { return 0; } +static int prom_get_datetime(void *, const MYSQL_TIME *, uint) { return 0; } + +static int prom_get_string(void *ctx, const char *value, size_t length, + const CHARSET_INFO *) { + auto *mc = static_cast(ctx); + if (mc->col_index == 0) { + mc->current_name.assign(value, length); + } else if (mc->col_index == 1) { + mc->current_value.assign(value, length); + } + mc->col_index++; + return 0; +} + +static void prom_handle_ok(void *, uint, uint, ulonglong, ulonglong, + const char *) {} + +static void prom_handle_error(void *ctx, uint, const char *, const char *) { + auto *mc = static_cast(ctx); + mc->error = true; + g_errors_total.fetch_add(1, std::memory_order_relaxed); +} + +static void prom_shutdown(void *, int) {} + +static const struct st_command_service_cbs prom_cbs = { + prom_start_result_metadata, + prom_field_metadata, + prom_end_result_metadata, + prom_start_row, + prom_end_row, + prom_abort_row, + prom_get_client_capabilities, + prom_get_null, + prom_get_integer, + prom_get_longlong, + prom_get_decimal, + prom_get_double, + prom_get_date, + prom_get_time, + prom_get_datetime, + prom_get_string, + prom_handle_ok, + prom_handle_error, + prom_shutdown, + nullptr, // connection_alive +}; + +struct InnodbMetricsCtx { + std::string *output; + std::string current_name; + std::string current_type; + std::string current_count; + int col_index; + bool error; +}; + +static int innodb_start_row(void *ctx) { + auto *mc = static_cast(ctx); + mc->col_index = 0; + mc->current_name.clear(); + mc->current_type.clear(); + mc->current_count.clear(); + return 0; +} + +static int innodb_end_row(void *ctx) { + auto *mc = static_cast(ctx); + + if (mc->current_name.empty() || mc->current_count.empty()) return 0; + + // Map InnoDB TYPE to Prometheus type: "counter" -> counter, else gauge + const char *prom_type = (mc->current_type == "counter") ? "counter" : "gauge"; + + // Build metric name: mysql_innodb_metrics_ + lowercase(name) + std::string prom_name = "mysql_innodb_metrics_"; + for (const char *p = mc->current_name.c_str(); *p != '\0'; ++p) { + prom_name += static_cast(tolower(static_cast(*p))); + } + + *mc->output += "# TYPE "; + *mc->output += prom_name; + *mc->output += ' '; + *mc->output += prom_type; + *mc->output += '\n'; + *mc->output += prom_name; + *mc->output += ' '; + *mc->output += mc->current_count; + *mc->output += '\n'; + + return 0; +} + +static int innodb_get_string(void *ctx, const char *value, size_t length, + const CHARSET_INFO *) { + auto *mc = static_cast(ctx); + if (mc->col_index == 0) { + mc->current_name.assign(value, length); + } else if (mc->col_index == 2) { + mc->current_type.assign(value, length); + } else if (mc->col_index == 3) { + mc->current_count.assign(value, length); + } + mc->col_index++; + return 0; +} + +static void innodb_handle_error(void *ctx, uint, const char *, const char *) { + auto *mc = static_cast(ctx); + mc->error = true; + g_errors_total.fetch_add(1, std::memory_order_relaxed); +} + +static const struct st_command_service_cbs innodb_cbs = { + prom_start_result_metadata, + prom_field_metadata, + prom_end_result_metadata, + innodb_start_row, + innodb_end_row, + prom_abort_row, + prom_get_client_capabilities, + prom_get_null, + prom_get_integer, + prom_get_longlong, + prom_get_decimal, + prom_get_double, + prom_get_date, + prom_get_time, + prom_get_datetime, + innodb_get_string, + prom_handle_ok, + innodb_handle_error, + prom_shutdown, + nullptr, // connection_alive +}; + +static bool command_failed(int command_fail, bool callback_error) { + if (!command_fail && !callback_error) return false; + + if (command_fail && !callback_error) { + g_errors_total.fetch_add(1, std::memory_order_relaxed); + } + return true; +} + +static bool collect_innodb_metrics(MYSQL_SESSION session, std::string &output) { + InnodbMetricsCtx mc; + mc.output = &output; + mc.col_index = 0; + mc.error = false; + + COM_DATA cmd; + memset(&cmd, 0, sizeof(cmd)); + cmd.com_query.query = + "SELECT NAME, SUBSYSTEM, TYPE, COUNT " + "FROM information_schema.INNODB_METRICS " + "WHERE STATUS='enabled'"; + cmd.com_query.length = strlen(cmd.com_query.query); + + const int fail = command_service_run_command( + session, COM_QUERY, &cmd, &my_charset_utf8mb3_general_ci, &innodb_cbs, + CS_TEXT_REPRESENTATION, &mc); + return !command_failed(fail, mc.error); +} + +struct ReplicaStatusCtx { + std::string *output; + std::vector col_names; + std::vector col_is_null; + std::vector col_values; + std::vector type_emitted; + int col_index; + bool has_row; + bool error; +}; + +static int replica_start_result_metadata(void *ctx, uint num_cols, uint, + const CHARSET_INFO *) { + auto *rc = static_cast(ctx); + rc->col_names.clear(); + rc->col_names.reserve(num_cols); + return 0; +} + +static int replica_field_metadata(void *ctx, struct st_send_field *field, + const CHARSET_INFO *) { + auto *rc = static_cast(ctx); + rc->col_names.push_back(field->col_name); + return 0; +} + +static int replica_start_row(void *ctx) { + auto *rc = static_cast(ctx); + rc->col_is_null.clear(); + rc->col_is_null.resize(rc->col_names.size(), false); + rc->col_values.clear(); + rc->col_values.resize(rc->col_names.size()); + rc->col_index = 0; + rc->has_row = true; + return 0; +} + +static int replica_get_string(void *ctx, const char *value, size_t length, + const CHARSET_INFO *) { + auto *rc = static_cast(ctx); + if (rc->col_index < static_cast(rc->col_values.size())) { + rc->col_is_null[rc->col_index] = false; + rc->col_values[rc->col_index].assign(value, length); + } + rc->col_index++; + return 0; +} + +static int replica_get_integer(void *ctx, longlong value) { + auto *rc = static_cast(ctx); + if (rc->col_index < static_cast(rc->col_values.size())) { + rc->col_is_null[rc->col_index] = false; + rc->col_values[rc->col_index] = std::to_string(value); + } + rc->col_index++; + return 0; +} + +static int replica_get_longlong(void *ctx, longlong value, uint is_unsigned) { + auto *rc = static_cast(ctx); + if (rc->col_index < static_cast(rc->col_values.size())) { + rc->col_is_null[rc->col_index] = false; + rc->col_values[rc->col_index] = + is_unsigned ? std::to_string(static_cast(value)) + : std::to_string(value); + } + rc->col_index++; + return 0; +} + +static int replica_get_double(void *ctx, double value, uint32) { + auto *rc = static_cast(ctx); + if (rc->col_index < static_cast(rc->col_values.size())) { + rc->col_is_null[rc->col_index] = false; + rc->col_values[rc->col_index] = std::to_string(value); + } + rc->col_index++; + return 0; +} + +static int replica_get_null(void *ctx) { + auto *rc = static_cast(ctx); + if (rc->col_index < static_cast(rc->col_is_null.size())) { + rc->col_is_null[rc->col_index] = true; + } + rc->col_index++; + return 0; +} + +struct ReplicaWantedField { + const char *col_name; + const char *metric_name; + bool is_bool; +}; + +static const ReplicaWantedField replica_wanted_fields[] = { + {"Seconds_Behind_Source", "mysql_replica_seconds_behind_source", false}, + {"Replica_IO_Running", "mysql_replica_io_running", true}, + {"Replica_SQL_Running", "mysql_replica_sql_running", true}, + {"Relay_Log_Space", "mysql_replica_relay_log_space", false}, + {"Exec_Source_Log_Pos", "mysql_replica_exec_source_log_pos", false}, + {"Read_Source_Log_Pos", "mysql_replica_read_source_log_pos", false}, +}; + +static int find_replica_column_index(const ReplicaStatusCtx &ctx, + const char *col_name) { + for (int i = 0; i < static_cast(ctx.col_names.size()); ++i) { + if (ctx.col_names[i] == col_name) return i; + } + return -1; +} + +static void append_prometheus_label_value(std::string &output, + const std::string &value) { + for (char ch : value) { + switch (ch) { + case '\\': + output += "\\\\"; + break; + case '"': + output += "\\\""; + break; + case '\n': + output += "\\n"; + break; + default: + output += ch; + break; + } + } +} + +static void append_replica_channel_label(std::string &output, + const std::string &channel_name) { + if (channel_name.empty()) return; + + output += "{channel=\""; + append_prometheus_label_value(output, channel_name); + output += "\"}"; +} + +static int replica_end_row(void *ctx) { + auto *rc = static_cast(ctx); + const int channel_idx = find_replica_column_index(*rc, "Channel_Name"); + const std::string channel_name = + (channel_idx >= 0 && + channel_idx < static_cast(rc->col_values.size())) + ? rc->col_values[channel_idx] + : ""; + + for (size_t wanted_idx = 0; + wanted_idx < array_elements(replica_wanted_fields); ++wanted_idx) { + const auto &wanted = replica_wanted_fields[wanted_idx]; + const int idx = find_replica_column_index(*rc, wanted.col_name); + if (idx < 0 || idx >= static_cast(rc->col_values.size())) continue; + + const bool is_null = + idx < static_cast(rc->col_is_null.size()) && rc->col_is_null[idx]; + const std::string &val = rc->col_values[idx]; + + std::string value_str; + if (wanted.is_bool) { + if (is_null || val.empty()) continue; + value_str = (val == "Yes") ? "1" : "0"; + } else { + if (is_null) { + if (strcmp(wanted.col_name, "Seconds_Behind_Source") != 0) continue; + value_str = "NaN"; + } else { + if (val.empty()) continue; + // Check if numeric -- require full-string consumption + const char *start = val.c_str(); + char *end = nullptr; + strtod(start, &end); + if (end == start || *end != '\0') continue; // not numeric, skip + value_str = val; + } + } + + if (!rc->type_emitted[wanted_idx]) { + *rc->output += "# TYPE "; + *rc->output += wanted.metric_name; + *rc->output += " gauge\n"; + rc->type_emitted[wanted_idx] = true; + } + *rc->output += wanted.metric_name; + append_replica_channel_label(*rc->output, channel_name); + *rc->output += ' '; + *rc->output += value_str; + *rc->output += '\n'; + } + + return 0; +} + +static void replica_handle_error(void *ctx, uint, const char *, const char *) { + auto *rc = static_cast(ctx); + rc->error = true; + g_errors_total.fetch_add(1, std::memory_order_relaxed); +} + +static const struct st_command_service_cbs replica_cbs = { + replica_start_result_metadata, + replica_field_metadata, + prom_end_result_metadata, + replica_start_row, + replica_end_row, + prom_abort_row, + prom_get_client_capabilities, + replica_get_null, + replica_get_integer, + replica_get_longlong, + prom_get_decimal, + replica_get_double, + prom_get_date, + prom_get_time, + prom_get_datetime, + replica_get_string, + prom_handle_ok, + replica_handle_error, + prom_shutdown, + nullptr, // connection_alive +}; + +static bool collect_replica_status(MYSQL_SESSION session, std::string &output) { + ReplicaStatusCtx rc; + rc.output = &output; + rc.type_emitted.assign(array_elements(replica_wanted_fields), false); + rc.col_index = 0; + rc.has_row = false; + rc.error = false; + + COM_DATA cmd; + memset(&cmd, 0, sizeof(cmd)); + cmd.com_query.query = "SHOW REPLICA STATUS"; + cmd.com_query.length = strlen(cmd.com_query.query); + + const int fail = command_service_run_command( + session, COM_QUERY, &cmd, &my_charset_utf8mb3_general_ci, &replica_cbs, + CS_TEXT_REPRESENTATION, &rc); + return !command_failed(fail, rc.error); +} + +struct BinlogCtx { + std::string *output; + int col_index; + int file_count; + long long total_size; + std::string current_size; + bool error; +}; + +static int binlog_start_row(void *ctx) { + auto *bc = static_cast(ctx); + bc->col_index = 0; + bc->current_size.clear(); + return 0; +} + +static int binlog_end_row(void *ctx) { + auto *bc = static_cast(ctx); + bc->file_count++; + if (!bc->current_size.empty()) { + const char *start = bc->current_size.c_str(); + char *end = nullptr; + long long sz = strtoll(start, &end, 10); + if (end != start && *end == '\0' && sz >= 0 && + bc->total_size <= LLONG_MAX - sz) { + bc->total_size += sz; + } + } + return 0; +} + +static int binlog_get_string(void *ctx, const char *value, size_t length, + const CHARSET_INFO *) { + auto *bc = static_cast(ctx); + if (bc->col_index == 1) { + bc->current_size.assign(value, length); + } + bc->col_index++; + return 0; +} + +static void binlog_handle_ok(void *ctx, uint, uint, ulonglong, ulonglong, + const char *) { + auto *bc = static_cast(ctx); + if (bc->file_count > 0) { + *bc->output += "# TYPE mysql_binlog_file_count gauge\n"; + *bc->output += "mysql_binlog_file_count "; + *bc->output += std::to_string(bc->file_count); + *bc->output += '\n'; + + *bc->output += "# TYPE mysql_binlog_size_bytes_total gauge\n"; + *bc->output += "mysql_binlog_size_bytes_total "; + *bc->output += std::to_string(bc->total_size); + *bc->output += '\n'; + } +} + +static void binlog_handle_error(void *ctx, uint, const char *, const char *) { + auto *bc = static_cast(ctx); + bc->error = true; + g_errors_total.fetch_add(1, std::memory_order_relaxed); +} + +static const struct st_command_service_cbs binlog_cbs = { + prom_start_result_metadata, + prom_field_metadata, + prom_end_result_metadata, + binlog_start_row, + binlog_end_row, + prom_abort_row, + prom_get_client_capabilities, + prom_get_null, + prom_get_integer, + prom_get_longlong, + prom_get_decimal, + prom_get_double, + prom_get_date, + prom_get_time, + prom_get_datetime, + binlog_get_string, + binlog_handle_ok, + binlog_handle_error, + prom_shutdown, + nullptr, // connection_alive +}; + +static bool collect_binlog(MYSQL_SESSION session, std::string &output) { + BinlogCtx bc; + bc.output = &output; + bc.col_index = 0; + bc.file_count = 0; + bc.total_size = 0; + bc.error = false; + + COM_DATA cmd; + memset(&cmd, 0, sizeof(cmd)); + cmd.com_query.query = "SHOW BINARY LOGS"; + cmd.com_query.length = strlen(cmd.com_query.query); + + const int fail = command_service_run_command( + session, COM_QUERY, &cmd, &my_charset_utf8mb3_general_ci, &binlog_cbs, + CS_TEXT_REPRESENTATION, &bc); + return !command_failed(fail, bc.error); +} + +static bool collect_name_value_query(MYSQL_SESSION session, std::string &output, + const char *query, const char *prefix, + type_fn_t type_fn) { + MetricsCollectorCtx mc; + mc.output = &output; + mc.prefix = prefix; + mc.type_fn = type_fn; + mc.col_index = 0; + mc.error = false; + + COM_DATA cmd; + memset(&cmd, 0, sizeof(cmd)); + cmd.com_query.query = query; + cmd.com_query.length = strlen(query); + + const int fail = command_service_run_command( + session, COM_QUERY, &cmd, &my_charset_utf8mb3_general_ci, &prom_cbs, + CS_TEXT_REPRESENTATION, &mc); + return !command_failed(fail, mc.error); +} + +static bool collect_global_status(MYSQL_SESSION session, std::string &output) { + return collect_name_value_query(session, output, "SHOW GLOBAL STATUS", + "mysql_global_status_", global_status_type); +} + +static const char *global_variables_type([[maybe_unused]] const char *name) { + return "gauge"; +} + +static bool collect_global_variables(MYSQL_SESSION session, + std::string &output) { + return collect_name_value_query(session, output, "SHOW GLOBAL VARIABLES", + "mysql_global_variables_", + global_variables_type); +} + +struct ScrapeResult { + int http_status; + const char *reason_phrase; + std::string body; +}; + +static ScrapeResult make_scrape_error(int http_status, + const char *reason_phrase, + const char *body) { + return {http_status, reason_phrase, body}; +} + +static ScrapeResult collect_metrics() { + if (!srv_session_server_is_available()) { + g_errors_total.fetch_add(1, std::memory_order_relaxed); + return make_scrape_error(503, "Service Unavailable", + "# Server not available\n"); + } + + MYSQL_SESSION session = srv_session_open(nullptr, nullptr); + if (session == nullptr) { + g_errors_total.fetch_add(1, std::memory_order_relaxed); + return make_scrape_error(500, "Internal Server Error", + "# Failed to open session\n"); + } + + const char *user = prom_security_user ? prom_security_user : "root"; + MYSQL_SECURITY_CONTEXT sc; + if (thd_get_security_context(srv_session_info_get_thd(session), &sc) || + security_context_lookup(sc, user, "localhost", "127.0.0.1", "")) { + srv_session_close(session); + g_errors_total.fetch_add(1, std::memory_order_relaxed); + return make_scrape_error( + 500, "Internal Server Error", + "# Failed to set security context (user missing or lacks " + "privileges?)\n"); + } + + std::string output; + if (!collect_global_status(session, output)) { + srv_session_close(session); + return make_scrape_error(500, "Internal Server Error", + "# Failed to collect global status metrics\n"); + } + if (!collect_global_variables(session, output)) { + srv_session_close(session); + return make_scrape_error(500, "Internal Server Error", + "# Failed to collect global variable metrics\n"); + } + if (!collect_innodb_metrics(session, output)) { + srv_session_close(session); + return make_scrape_error(500, "Internal Server Error", + "# Failed to collect InnoDB metrics\n"); + } + if (!collect_replica_status(session, output)) { + srv_session_close(session); + return make_scrape_error(500, "Internal Server Error", + "# Failed to collect replica metrics\n"); + } + if (!collect_binlog(session, output)) { + } + + srv_session_close(session); + + return {200, "OK", output}; +} + +static int setup_listen_socket(const char *bind_addr, unsigned int port) { + if (bind_addr == nullptr || *bind_addr == '\0') { + return -1; + } + + int fd = socket(AF_INET, SOCK_STREAM, 0); + if (fd < 0) return -1; + + int reuse = 1; + setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, &reuse, sizeof(reuse)); + + struct sockaddr_in addr; + memset(&addr, 0, sizeof(addr)); + addr.sin_family = AF_INET; + addr.sin_port = htons(static_cast(port)); + if (inet_pton(AF_INET, bind_addr, &addr.sin_addr) != 1) { + close(fd); + return -1; + } + + if (bind(fd, reinterpret_cast(&addr), sizeof(addr)) < 0) { + close(fd); + return -1; + } + + if (listen(fd, 5) < 0) { + close(fd); + return -1; + } + + return fd; +} + +static void write_full(int fd, const char *buf, size_t len) { + size_t written = 0; + while (written < len) { + ssize_t n = send(fd, buf + written, len - written, MSG_NOSIGNAL); + if (n < 0) { + if (errno == EINTR) continue; + break; // EAGAIN (timeout), EPIPE, ECONNRESET, etc. -- give up + } + if (n == 0) break; + written += static_cast(n); + } +} + +static ssize_t read_http_request(int fd, char *buf, size_t max_len) { + size_t total = 0; + while (total < max_len - 1) { + ssize_t n = recv(fd, buf + total, max_len - 1 - total, 0); + if (n < 0) { + if (errno == EINTR) continue; + return -1; // timeout or error + } + if (n == 0) break; // client closed + total += static_cast(n); + buf[total] = '\0'; + // Check if we have the full request headers + if (strstr(buf, "\r\n\r\n") != nullptr) break; + // Or at least the request line for simple requests + if (strstr(buf, "\r\n") != nullptr && total >= 13) break; + } + buf[total] = '\0'; + return static_cast(total); +} + +static void *prometheus_listener_thread(void *arg) { + auto *ctx = static_cast(arg); + + // Initialize srv_session thread-local state once for this physical thread. + // Per the MySQL session service contract, this must be called once per + // thread that will use the session service, not once per request. + if (srv_session_init_thread(ctx->plugin_ref) != 0) { + LogPluginErrMsg(ERROR_LEVEL, ER_LOG_PRINTF_MSG, + "Prometheus exporter: failed to init session thread"); + return nullptr; + } + + while (!ctx->shutdown_requested.load(std::memory_order_acquire)) { + struct pollfd pfds[2]; + pfds[0].fd = ctx->listen_fd; + pfds[0].events = POLLIN; + pfds[0].revents = 0; + pfds[1].fd = ctx->wakeup_fd; + pfds[1].events = POLLIN; + pfds[1].revents = 0; + + int ret = poll(pfds, 2, -1); // block until wakeup or new connection + if (ret < 0) { + if (errno == EINTR) continue; + break; // fatal poll error + } + + // Check wakeup fd first + if (pfds[1].revents & POLLIN) break; + if (!(pfds[0].revents & POLLIN)) continue; + + int client_fd = accept(ctx->listen_fd, nullptr, nullptr); + if (client_fd < 0) { + if (errno == EINTR || errno == EAGAIN || errno == EWOULDBLOCK || + errno == ECONNABORTED) + continue; + break; // fatal accept error + } + + // Set receive timeout to avoid blocking indefinitely on slow clients + struct timeval tv; + tv.tv_sec = 5; + tv.tv_usec = 0; + setsockopt(client_fd, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof(tv)); + setsockopt(client_fd, SOL_SOCKET, SO_SNDTIMEO, &tv, sizeof(tv)); + + // Read HTTP request + char buf[4096]; + ssize_t n = read_http_request(client_fd, buf, sizeof(buf)); + if (n <= 0) { + close(client_fd); + continue; + } + + if (n >= 12 && strncmp(buf, "GET /metrics", 12) == 0 && + (buf[12] == ' ' || buf[12] == '?' || buf[12] == '\r' || + buf[12] == '\0')) { + g_requests_total.fetch_add(1, std::memory_order_relaxed); + + auto start = std::chrono::steady_clock::now(); + ScrapeResult scrape = collect_metrics(); + auto elapsed = std::chrono::duration_cast( + std::chrono::steady_clock::now() - start); + g_last_scrape_duration_us.store(static_cast(elapsed.count()), + std::memory_order_relaxed); + + std::string response = + "HTTP/1.1 " + std::to_string(scrape.http_status) + " " + + scrape.reason_phrase + + "\r\n" + "Content-Type: text/plain; version=0.0.4; charset=utf-8\r\n" + "Content-Length: " + + std::to_string(scrape.body.size()) + + "\r\n" + "Connection: close\r\n" + "\r\n" + + scrape.body; + + write_full(client_fd, response.c_str(), response.size()); + } else { + const char *resp_404 = + "HTTP/1.1 404 Not Found\r\n" + "Connection: close\r\n" + "\r\n"; + write_full(client_fd, resp_404, strlen(resp_404)); + } + close(client_fd); + } + + srv_session_deinit_thread(); + return nullptr; +} + +static int prometheus_exporter_init(void *p) { + auto *plugin = static_cast(p); + + if (init_logging_service_for_plugin(®_srv, &log_bi, &log_bs)) return 1; + + if (!prom_enabled) { + LogPluginErr(INFORMATION_LEVEL, ER_LOG_PRINTF_MSG, + "Prometheus exporter plugin installed but not enabled. " + "Set --prometheus-exporter-enabled=ON to activate."); + plugin->data = nullptr; + return 0; + } + + auto *ctx = new (std::nothrow) PrometheusContext(); + if (ctx == nullptr) { + deinit_logging_service_for_plugin(®_srv, &log_bi, &log_bs); + return 1; + } + ctx->plugin_ref = p; + + ctx->listen_fd = setup_listen_socket(prom_bind_address, prom_port); + if (ctx->listen_fd < 0) { + LogPluginErr(ERROR_LEVEL, ER_LOG_PRINTF_MSG, + "Prometheus exporter: failed to bind to %s:%u", + prom_bind_address ? prom_bind_address : "(null)", prom_port); + delete ctx; + deinit_logging_service_for_plugin(®_srv, &log_bi, &log_bs); + return 1; + } + + ctx->wakeup_fd = eventfd(0, EFD_CLOEXEC); + if (ctx->wakeup_fd < 0) { + close(ctx->listen_fd); + delete ctx; + deinit_logging_service_for_plugin(®_srv, &log_bi, &log_bs); + return 1; + } + + my_thread_attr_t attr; + my_thread_attr_init(&attr); + my_thread_attr_setdetachstate(&attr, MY_THREAD_CREATE_JOINABLE); + + if (my_thread_create(&ctx->listener_thread, &attr, prometheus_listener_thread, + ctx) != 0) { + LogPluginErr(ERROR_LEVEL, ER_LOG_PRINTF_MSG, + "Prometheus exporter: failed to create listener thread"); + close(ctx->listen_fd); + close(ctx->wakeup_fd); + delete ctx; + deinit_logging_service_for_plugin(®_srv, &log_bi, &log_bs); + return 1; + } + + LogPluginErr(INFORMATION_LEVEL, ER_LOG_PRINTF_MSG, + "Prometheus exporter listening on %s:%u", prom_bind_address, + prom_port); + + if (prom_bind_address != nullptr && + strcmp(prom_bind_address, "127.0.0.1") != 0) { + LogPluginErr(WARNING_LEVEL, ER_LOG_PRINTF_MSG, + "Prometheus exporter is bound to %s which is not a " + "loopback address. The /metrics endpoint has no " + "authentication or TLS -- ensure network access to " + "port %u is restricted.", + prom_bind_address, prom_port); + } + + plugin->data = ctx; + return 0; +} + +static int prometheus_exporter_deinit(void *p) { + auto *plugin = static_cast(p); + auto *ctx = static_cast(plugin->data); + + if (ctx != nullptr) { + ctx->shutdown_requested.store(true, std::memory_order_release); + if (ctx->wakeup_fd >= 0) { + uint64_t val = 1; + ssize_t r = write(ctx->wakeup_fd, &val, sizeof(val)); + (void)r; // ignore errors; at worst the listener wakes via EINTR + } + + void *dummy; + my_thread_join(&ctx->listener_thread, &dummy); + + // Now it's safe to close the fds + if (ctx->listen_fd >= 0) close(ctx->listen_fd); + if (ctx->wakeup_fd >= 0) close(ctx->wakeup_fd); + + delete ctx; + plugin->data = nullptr; + } + + deinit_logging_service_for_plugin(®_srv, &log_bi, &log_bs); + return 0; +} + +static int show_requests_total(MYSQL_THD, SHOW_VAR *var, char *buff) { + var->type = SHOW_LONGLONG; + var->value = buff; + longlong v = + static_cast(g_requests_total.load(std::memory_order_relaxed)); + memcpy(buff, &v, sizeof(v)); + return 0; +} + +static int show_errors_total(MYSQL_THD, SHOW_VAR *var, char *buff) { + var->type = SHOW_LONGLONG; + var->value = buff; + longlong v = + static_cast(g_errors_total.load(std::memory_order_relaxed)); + memcpy(buff, &v, sizeof(v)); + return 0; +} + +static int show_scrape_duration(MYSQL_THD, SHOW_VAR *var, char *buff) { + var->type = SHOW_LONGLONG; + var->value = buff; + longlong v = static_cast( + g_last_scrape_duration_us.load(std::memory_order_relaxed)); + memcpy(buff, &v, sizeof(v)); + return 0; +} + +static SHOW_VAR prom_status_vars[] = { + {"Prometheus_exporter_requests_total", + reinterpret_cast(&show_requests_total), SHOW_FUNC, + SHOW_SCOPE_GLOBAL}, + {"Prometheus_exporter_errors_total", + reinterpret_cast(&show_errors_total), SHOW_FUNC, + SHOW_SCOPE_GLOBAL}, + {"Prometheus_exporter_scrape_duration_microseconds", + reinterpret_cast(&show_scrape_duration), SHOW_FUNC, + SHOW_SCOPE_GLOBAL}, + {nullptr, nullptr, SHOW_UNDEF, SHOW_SCOPE_UNDEF}, +}; + +static struct st_mysql_daemon prometheus_exporter_descriptor = { + MYSQL_DAEMON_INTERFACE_VERSION}; + +mysql_declare_plugin(prometheus_exporter){ + MYSQL_DAEMON_PLUGIN, + &prometheus_exporter_descriptor, + "prometheus_exporter", + "VillageSQL Authors", + "Embedded Prometheus metrics exporter for MySQL/VillageSQL", + PLUGIN_LICENSE_GPL, + prometheus_exporter_init, + nullptr, + prometheus_exporter_deinit, + 0x0100, + prom_status_vars, + prom_system_vars, + nullptr, + 0, +} mysql_declare_plugin_end;