diff --git a/Docs/prometheus_exporter.md b/Docs/prometheus_exporter.md new file mode 100644 index 000000000000..2159796efd7c --- /dev/null +++ b/Docs/prometheus_exporter.md @@ -0,0 +1,6 @@ +# Prometheus Exporter Plugin + +Embedded Prometheus metrics exporter for VillageSQL. + +For full documentation, architecture, and configuration reference, 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/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_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/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..0b69d3648cc1 --- /dev/null +++ b/mysql-test/suite/prometheus_exporter/t/binlog-master.opt @@ -0,0 +1 @@ +$PROMETHEUS_EXPORTER_PLUGIN_OPT $PROMETHEUS_EXPORTER_PLUGIN_LOAD --prometheus-exporter-enabled=ON --prometheus-exporter-port=19108 --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..7cef1f53a9e2 --- /dev/null +++ b/mysql-test/suite/prometheus_exporter/t/binlog.test @@ -0,0 +1,11 @@ +--source include/not_windows.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..0e0227d8a376 --- /dev/null +++ b/mysql-test/suite/prometheus_exporter/t/format_validation-master.opt @@ -0,0 +1 @@ +$PROMETHEUS_EXPORTER_PLUGIN_OPT $PROMETHEUS_EXPORTER_PLUGIN_LOAD --prometheus-exporter-enabled=ON --prometheus-exporter-port=19109 --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..c09ca3c0a9e5 --- /dev/null +++ b/mysql-test/suite/prometheus_exporter/t/format_validation.test @@ -0,0 +1,85 @@ +# ============================================================================= +# 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 names match [a-z_][a-z0-9_]* +# - Every metric value is numeric or NaN/+Inf/-Inf per Prometheus spec +# ============================================================================= + +--source include/not_windows.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; + +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)$/) { + $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_]*) (.+)$/) { + my ($name, $value) = ($1, $2); + $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..d579becff220 --- /dev/null +++ b/mysql-test/suite/prometheus_exporter/t/global_variables-master.opt @@ -0,0 +1 @@ +$PROMETHEUS_EXPORTER_PLUGIN_OPT $PROMETHEUS_EXPORTER_PLUGIN_LOAD --prometheus-exporter-enabled=ON --prometheus-exporter-port=19105 --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..f7baaacb8f7f --- /dev/null +++ b/mysql-test/suite/prometheus_exporter/t/global_variables.test @@ -0,0 +1,11 @@ +--source include/not_windows.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..3b3491bfbfb8 --- /dev/null +++ b/mysql-test/suite/prometheus_exporter/t/innodb_metrics-master.opt @@ -0,0 +1 @@ +$PROMETHEUS_EXPORTER_PLUGIN_OPT $PROMETHEUS_EXPORTER_PLUGIN_LOAD --prometheus-exporter-enabled=ON --prometheus-exporter-port=19106 --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..552b06b26429 --- /dev/null +++ b/mysql-test/suite/prometheus_exporter/t/innodb_metrics.test @@ -0,0 +1,10 @@ +--source include/not_windows.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..d6c3c6f5a7df --- /dev/null +++ b/mysql-test/suite/prometheus_exporter/t/metrics_endpoint-master.opt @@ -0,0 +1 @@ +$PROMETHEUS_EXPORTER_PLUGIN_OPT $PROMETHEUS_EXPORTER_PLUGIN_LOAD --prometheus-exporter-enabled=ON --prometheus-exporter-port=19104 --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..670d1717f118 --- /dev/null +++ b/mysql-test/suite/prometheus_exporter/t/metrics_endpoint.test @@ -0,0 +1,37 @@ +# ============================================================================= +# 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 + +# 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_status-master.opt b/mysql-test/suite/prometheus_exporter/t/replica_status-master.opt new file mode 100644 index 000000000000..fff7ec762753 --- /dev/null +++ b/mysql-test/suite/prometheus_exporter/t/replica_status-master.opt @@ -0,0 +1 @@ +$PROMETHEUS_EXPORTER_PLUGIN_OPT $PROMETHEUS_EXPORTER_PLUGIN_LOAD --prometheus-exporter-enabled=ON --prometheus-exporter-port=19107 --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..45fcb07065b3 --- /dev/null +++ b/mysql-test/suite/prometheus_exporter/t/replica_status.test @@ -0,0 +1,7 @@ +--source include/not_windows.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..9d8667fbe197 --- /dev/null +++ b/mysql-test/suite/prometheus_exporter/t/scrape_counter-master.opt @@ -0,0 +1 @@ +$PROMETHEUS_EXPORTER_PLUGIN_OPT $PROMETHEUS_EXPORTER_PLUGIN_LOAD --prometheus-exporter-enabled=ON --prometheus-exporter-port=19110 --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..4956763cccea --- /dev/null +++ b/mysql-test/suite/prometheus_exporter/t/scrape_counter.test @@ -0,0 +1,35 @@ +# ============================================================================= +# 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 + +--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/plugin/prometheus_exporter/CMakeLists.txt b/plugin/prometheus_exporter/CMakeLists.txt new file mode 100644 index 000000000000..ef5f067e2f69 --- /dev/null +++ b/plugin/prometheus_exporter/CMakeLists.txt @@ -0,0 +1,20 @@ +# Copyright (c) 2025, VillageSQL and/or its affiliates. +# +# 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 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 + +MYSQL_ADD_PLUGIN(prometheus_exporter + prometheus_exporter.cc + MODULE_ONLY + MODULE_OUTPUT_NAME "prometheus_exporter" +) diff --git a/plugin/prometheus_exporter/README.md b/plugin/prometheus_exporter/README.md new file mode 100644 index 000000000000..6ed13c5d0839 --- /dev/null +++ b/plugin/prometheus_exporter/README.md @@ -0,0 +1,155 @@ +# 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). + +| 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 | IP address to bind to | + +## 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 +``` + +### Load at runtime + +```sql +INSTALL PLUGIN prometheus_exporter SONAME 'prometheus_exporter.so'; +-- Note: enabled defaults to OFF, so the HTTP server won't start +-- unless --prometheus-exporter-enabled=ON was set at startup +``` + +### 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. + +**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**: Uses POSIX sockets (socket/bind/listen/accept/poll). + Windows support would require Winsock adaptation. +- **Read-only variables**: Port and bind address require a server restart + to change. diff --git a/plugin/prometheus_exporter/prometheus_exporter.cc b/plugin/prometheus_exporter/prometheus_exporter.cc new file mode 100644 index 000000000000..46c8394feb48 --- /dev/null +++ b/plugin/prometheus_exporter/prometheus_exporter.cc @@ -0,0 +1,1026 @@ +// Copyright (c) 2025, VillageSQL and/or its affiliates. +// +// 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 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; + +static bool prom_enabled = false; +static unsigned int prom_port = 9104; +static char *prom_bind_address = nullptr; +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, +}; + +// Module-scope counters. Lifetime independent of the context so that +// SHOW STATUS callbacks can safely read them even during plugin +// install/uninstall races. +static std::atomic g_requests_total{0}; +static std::atomic g_errors_total{0}; +static std::atomic g_last_scrape_duration_us{0}; + +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 void 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); + + command_service_run_command(session, COM_QUERY, &cmd, + &my_charset_utf8mb3_general_ci, &innodb_cbs, + CS_TEXT_REPRESENTATION, &mc); +} + +struct ReplicaStatusCtx { + std::string *output; + std::vector col_names; + std::vector col_values; + 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_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_values[rc->col_index].assign(value, length); + } + 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 replica_end_row(void *ctx) { + auto *rc = static_cast(ctx); + + for (const auto &wanted : replica_wanted_fields) { + // Find column index by name + int idx = -1; + for (int i = 0; i < static_cast(rc->col_names.size()); ++i) { + if (rc->col_names[i] == wanted.col_name) { + idx = i; + break; + } + } + if (idx < 0 || idx >= static_cast(rc->col_values.size())) continue; + + const std::string &val = rc->col_values[idx]; + if (val.empty()) continue; + + std::string value_str; + if (wanted.is_bool) { + value_str = (val == "Yes") ? "1" : "0"; + } else { + // 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; + } + + *rc->output += "# TYPE "; + *rc->output += wanted.metric_name; + *rc->output += " gauge\n"; + *rc->output += wanted.metric_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, + prom_get_null, + prom_get_integer, + prom_get_longlong, + prom_get_decimal, + prom_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 void collect_replica_status(MYSQL_SESSION session, std::string &output) { + ReplicaStatusCtx rc; + rc.output = &output; + 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); + + command_service_run_command(session, COM_QUERY, &cmd, + &my_charset_utf8mb3_general_ci, &replica_cbs, + CS_TEXT_REPRESENTATION, &rc); +} + +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 void 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); + + command_service_run_command(session, COM_QUERY, &cmd, + &my_charset_utf8mb3_general_ci, &binlog_cbs, + CS_TEXT_REPRESENTATION, &bc); +} + +static void 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); + + command_service_run_command(session, COM_QUERY, &cmd, + &my_charset_utf8mb3_general_ci, &prom_cbs, + CS_TEXT_REPRESENTATION, &mc); +} + +static void collect_global_status(MYSQL_SESSION session, std::string &output) { + 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 void collect_global_variables(MYSQL_SESSION session, + std::string &output) { + collect_name_value_query(session, output, "SHOW GLOBAL VARIABLES", + "mysql_global_variables_", global_variables_type); +} + +static std::string collect_metrics() { + if (!srv_session_server_is_available()) { + return "# Server not available\n"; + } + + MYSQL_SESSION session = srv_session_open(nullptr, nullptr); + if (session == nullptr) { + return "# Failed to open session\n"; + } + + // Switch to the configured security context user on localhost. The user + // must exist and have sufficient privileges to read the metrics sources + // (INNODB_METRICS, etc.). Default is "root" for backward compatibility; + // operators may configure a least-privilege account via the + // prometheus_exporter_security_user sysvar. + 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); + return "# Failed to set security context (user missing or lacks " + "privileges?)\n"; + } + + std::string output; + collect_global_status(session, output); + collect_global_variables(session, output); + collect_innodb_metrics(session, output); + collect_replica_status(session, output); + collect_binlog(session, output); + + srv_session_close(session); + + return 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(); + std::string body = 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 200 OK\r\n" + "Content-Type: text/plain; version=0.0.4; charset=utf-8\r\n" + "Content-Length: " + + std::to_string(body.size()) + + "\r\n" + "Connection: close\r\n" + "\r\n" + + 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 && + strcmp(prom_bind_address, "localhost") != 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;