From ed1cabaa504ffcd228c5b2ffc8bd651815a69c9d Mon Sep 17 00:00:00 2001 From: Rene Cannao Date: Sun, 5 Apr 2026 05:37:28 +0000 Subject: [PATCH 01/16] feat: add embedded Prometheus metrics exporter plugin (v1) Add a MySQL daemon plugin that serves Prometheus text exposition format metrics via an embedded HTTP endpoint. Uses srv_session + command_service to execute SHOW GLOBAL STATUS internally (no network, no auth). Features: - Disabled by default (--prometheus-exporter-enabled=ON to activate) - Configurable port (default 9104) and bind address - Exports ~400 global status variables as mysql_global_status_* metrics - Gauge/untyped type classification for known variables - Plugin self-monitoring: requests_total, errors_total, scrape_duration - Clean shutdown via poll() + atomic flag Includes MTR test suite (basic + metrics_endpoint) and design spec/plan for v2 enhancements. --- .../2026-04-05-prometheus-exporter-v2.md | 1365 +++++++++++++++++ ...026-04-05-prometheus-exporter-v2-design.md | 241 +++ mysql-test/include/plugin.defs | 3 + .../suite/prometheus_exporter/r/basic.result | 16 + .../r/metrics_endpoint.result | 16 + .../suite/prometheus_exporter/t/basic.test | 24 + .../t/metrics_endpoint-master.opt | 1 + .../t/metrics_endpoint.test | 22 + plugin/prometheus_exporter/CMakeLists.txt | 20 + .../prometheus_exporter.cc | 572 +++++++ 10 files changed, 2280 insertions(+) create mode 100644 docs/superpowers/plans/2026-04-05-prometheus-exporter-v2.md create mode 100644 docs/superpowers/specs/2026-04-05-prometheus-exporter-v2-design.md create mode 100644 mysql-test/suite/prometheus_exporter/r/basic.result create mode 100644 mysql-test/suite/prometheus_exporter/r/metrics_endpoint.result create mode 100644 mysql-test/suite/prometheus_exporter/t/basic.test create mode 100644 mysql-test/suite/prometheus_exporter/t/metrics_endpoint-master.opt create mode 100644 mysql-test/suite/prometheus_exporter/t/metrics_endpoint.test create mode 100644 plugin/prometheus_exporter/CMakeLists.txt create mode 100644 plugin/prometheus_exporter/prometheus_exporter.cc diff --git a/docs/superpowers/plans/2026-04-05-prometheus-exporter-v2.md b/docs/superpowers/plans/2026-04-05-prometheus-exporter-v2.md new file mode 100644 index 000000000000..668d638b8dbf --- /dev/null +++ b/docs/superpowers/plans/2026-04-05-prometheus-exporter-v2.md @@ -0,0 +1,1365 @@ +# Prometheus Exporter v2 Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Expand the prometheus_exporter plugin from 1 data source to 5, add 7 MTR tests with format validation, and create full documentation with architecture diagrams. + +**Architecture:** The plugin uses MySQL's `srv_session` + `command_service_run_command` API to execute SQL queries internally (no network). Each data source gets its own collector function that takes an open session and appends Prometheus-formatted text to an output string. A single `collect_metrics()` orchestrator opens one session, calls all collectors, then closes the session. + +**Tech Stack:** C++20, MySQL daemon plugin API, `srv_session` service, `command_service` callbacks, POSIX sockets, MTR test framework. + +**Spec:** `docs/superpowers/specs/2026-04-05-prometheus-exporter-v2-design.md` + +**Build commands:** +```bash +# Build just the plugin (fast, ~10s): +cd /data/rene/build && make -j32 prometheus_exporter + +# Run all prometheus_exporter tests: +cd /data/rene/build && ./mysql-test/mysql-test-run.pl --suite=prometheus_exporter --nounit-tests --parallel=auto + +# Run a specific test: +cd /data/rene/build && ./mysql-test/mysql-test-run.pl --suite=prometheus_exporter --nounit-tests + +# Record a test result: +cd /data/rene/build && ./mysql-test/mysql-test-run.pl --record --suite=prometheus_exporter --nounit-tests +``` + +--- + +## File Map + +| File | Action | Responsibility | +|------|--------|---------------| +| `plugin/prometheus_exporter/prometheus_exporter.cc` | Modify | Add 4 new collectors, refactor existing code into collector pattern, expand gauge list | +| `plugin/prometheus_exporter/README.md` | Create | Full documentation: architecture diagram, config reference, metric namespaces, usage | +| `Docs/prometheus_exporter.md` | Create | Pointer to full docs in plugin dir | +| `mysql-test/suite/prometheus_exporter/t/basic.test` | Modify | Add inline comments | +| `mysql-test/suite/prometheus_exporter/t/metrics_endpoint.test` | Modify | Verify all 5 data source prefixes | +| `mysql-test/suite/prometheus_exporter/t/global_variables.test` | Create | Test SHOW GLOBAL VARIABLES collector | +| `mysql-test/suite/prometheus_exporter/t/global_variables-master.opt` | Create | Server opts for test | +| `mysql-test/suite/prometheus_exporter/r/global_variables.result` | Create | Expected output | +| `mysql-test/suite/prometheus_exporter/t/innodb_metrics.test` | Create | Test INNODB_METRICS collector | +| `mysql-test/suite/prometheus_exporter/t/innodb_metrics-master.opt` | Create | Server opts for test | +| `mysql-test/suite/prometheus_exporter/r/innodb_metrics.result` | Create | Expected output | +| `mysql-test/suite/prometheus_exporter/t/replica_status.test` | Create | Test graceful absence on non-replica | +| `mysql-test/suite/prometheus_exporter/t/replica_status-master.opt` | Create | Server opts for test | +| `mysql-test/suite/prometheus_exporter/r/replica_status.result` | Create | Expected output | +| `mysql-test/suite/prometheus_exporter/t/binlog.test` | Create | Test SHOW BINARY LOGS collector | +| `mysql-test/suite/prometheus_exporter/t/binlog-master.opt` | Create | Server opts for test | +| `mysql-test/suite/prometheus_exporter/r/binlog.result` | Create | Expected output | +| `mysql-test/suite/prometheus_exporter/t/format_validation.test` | Create | Perl-based format validation | +| `mysql-test/suite/prometheus_exporter/t/format_validation-master.opt` | Create | Server opts for test | +| `mysql-test/suite/prometheus_exporter/r/format_validation.result` | Create | Expected output | +| `mysql-test/suite/prometheus_exporter/r/metrics_endpoint.result` | Modify | Updated expected output | + +--- + +## Task 1: Refactor existing code into collector pattern + +Refactor `collect_metrics()` so the existing SHOW GLOBAL STATUS logic is extracted into its own `collect_global_status()` function. This doesn't change behavior -- it's a structural refactor to enable adding more collectors cleanly. + +**Files:** +- Modify: `plugin/prometheus_exporter/prometheus_exporter.cc` + +- [ ] **Step 1: Refactor MetricsCollectorCtx to support configurable prefix and type function** + +In `prometheus_exporter.cc`, modify the `MetricsCollectorCtx` struct and `prom_end_row` to accept a prefix and a type-determination function pointer. Replace the current hardcoded `"mysql_global_status_"` prefix. + +Change the struct (around line 174): + +```cpp +// Function pointer type for determining Prometheus metric type +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; +}; +``` + +Change `prom_end_row` (around line 202) to use the context's prefix and type_fn: + +```cpp +static int prom_end_row(void *ctx) { + auto *mc = static_cast(ctx); + + if (mc->current_name.empty() || mc->current_value.empty()) return 0; + + char *end = nullptr; + strtod(mc->current_value.c_str(), &end); + if (end == mc->current_value.c_str()) return 0; + + 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; +} +``` + +Remove the standalone `to_prometheus_name()` function (line 162-168) since the prefix logic is now in `prom_end_row`. + +- [ ] **Step 2: Add type function for global status** + +Add the type function that wraps the existing `is_gauge()`: + +```cpp +static const char *global_status_type(const char *name) { + return is_gauge(name) ? "gauge" : "untyped"; +} +``` + +- [ ] **Step 3: Create a helper to run a 2-column query** + +This helper opens a query, uses the existing `prom_cbs` callbacks, and returns. It will be reused by both Global Status and Global Variables collectors: + +```cpp +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); +} +``` + +- [ ] **Step 4: Extract collect_global_status()** + +```cpp +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); +} +``` + +- [ ] **Step 5: Refactor collect_metrics() to use the new collector** + +Replace the body of `collect_metrics()` to separate session management from collection: + +```cpp +static std::string collect_metrics() { + if (!srv_session_server_is_available()) { + return "# Server not available\n"; + } + + if (srv_session_init_thread(g_ctx->plugin_ref) != 0) { + return "# Failed to init session thread\n"; + } + + MYSQL_SESSION session = srv_session_open(nullptr, nullptr); + if (session == nullptr) { + srv_session_deinit_thread(); + return "# Failed to open session\n"; + } + + MYSQL_SECURITY_CONTEXT sc; + thd_get_security_context(srv_session_info_get_thd(session), &sc); + security_context_lookup(sc, "root", "localhost", "127.0.0.1", ""); + + std::string output; + collect_global_status(session, output); + + srv_session_close(session); + srv_session_deinit_thread(); + + return output; +} +``` + +- [ ] **Step 6: Build and verify no behavior change** + +```bash +cd /data/rene/build && make -j32 prometheus_exporter +``` + +Expected: builds with no errors. + +- [ ] **Step 7: Run existing tests** + +```bash +cd /data/rene/build && ./mysql-test/mysql-test-run.pl --suite=prometheus_exporter --nounit-tests --parallel=auto +``` + +Expected: All 3 tests pass (basic, metrics_endpoint, shutdown_report). + +- [ ] **Step 8: Commit** + +```bash +git add plugin/prometheus_exporter/prometheus_exporter.cc +git commit -m "refactor: extract collector pattern from prometheus_exporter + +Refactor collect_metrics() to use a collector function pattern with +configurable prefix and type-determination function. Extracts +collect_global_status() and collect_name_value_query() helper. +No behavior change -- prepares for adding more collectors." +``` + +--- + +## Task 2: Add SHOW GLOBAL VARIABLES collector + +**Files:** +- Modify: `plugin/prometheus_exporter/prometheus_exporter.cc` +- Create: `mysql-test/suite/prometheus_exporter/t/global_variables.test` +- Create: `mysql-test/suite/prometheus_exporter/t/global_variables-master.opt` +- Create: `mysql-test/suite/prometheus_exporter/r/global_variables.result` + +- [ ] **Step 1: Write the test** + +Create `mysql-test/suite/prometheus_exporter/t/global_variables-master.opt`: +``` +$PROMETHEUS_EXPORTER_PLUGIN_OPT $PROMETHEUS_EXPORTER_PLUGIN_LOAD --prometheus-exporter-enabled=ON --prometheus-exporter-port=19105 --prometheus-exporter-bind-address=127.0.0.1 +``` + +Create `mysql-test/suite/prometheus_exporter/t/global_variables.test`: +```sql +--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 +``` + +- [ ] **Step 2: Add the type function and collector** + +In `prometheus_exporter.cc`, add after `collect_global_status()`: + +```cpp +static const char *global_variables_type(const char *) { 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); +} +``` + +- [ ] **Step 3: Wire into collect_metrics()** + +In `collect_metrics()`, add after the `collect_global_status(session, output);` line: + +```cpp + collect_global_variables(session, output); +``` + +- [ ] **Step 4: Build** + +```bash +cd /data/rene/build && make -j32 prometheus_exporter +``` + +Expected: builds with no errors. + +- [ ] **Step 5: Record and run test** + +```bash +cd /data/rene/build && ./mysql-test/mysql-test-run.pl --record --suite=prometheus_exporter --nounit-tests global_variables +``` + +Expected: PASS, result file generated. + +- [ ] **Step 6: Run all tests to verify no regressions** + +```bash +cd /data/rene/build && ./mysql-test/mysql-test-run.pl --suite=prometheus_exporter --nounit-tests --parallel=auto +``` + +Expected: All tests pass. + +- [ ] **Step 7: Commit** + +```bash +git add plugin/prometheus_exporter/prometheus_exporter.cc \ + mysql-test/suite/prometheus_exporter/t/global_variables.test \ + mysql-test/suite/prometheus_exporter/t/global_variables-master.opt \ + mysql-test/suite/prometheus_exporter/r/global_variables.result +git commit -m "feat(prometheus): add SHOW GLOBAL VARIABLES collector + +Exports server configuration values (max_connections, +innodb_buffer_pool_size, etc.) as mysql_global_variables_* gauge metrics. +Non-numeric variables are silently skipped." +``` + +--- + +## Task 3: Add INNODB_METRICS collector + +**Files:** +- Modify: `plugin/prometheus_exporter/prometheus_exporter.cc` +- Create: `mysql-test/suite/prometheus_exporter/t/innodb_metrics.test` +- Create: `mysql-test/suite/prometheus_exporter/t/innodb_metrics-master.opt` +- Create: `mysql-test/suite/prometheus_exporter/r/innodb_metrics.result` + +- [ ] **Step 1: Write the test** + +Create `mysql-test/suite/prometheus_exporter/t/innodb_metrics-master.opt`: +``` +$PROMETHEUS_EXPORTER_PLUGIN_OPT $PROMETHEUS_EXPORTER_PLUGIN_LOAD --prometheus-exporter-enabled=ON --prometheus-exporter-port=19106 --prometheus-exporter-bind-address=127.0.0.1 +``` + +Create `mysql-test/suite/prometheus_exporter/t/innodb_metrics.test`: +```sql +--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 counter type metric +--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 (value type in InnoDB) +--exec curl -s http://127.0.0.1:19106/metrics | grep "^# TYPE mysql_innodb_metrics_buffer_pool_size " | head -1 +``` + +- [ ] **Step 2: Add InnodbMetricsCtx and callbacks** + +This collector uses a 4-column result (NAME, SUBSYSTEM, TYPE, COUNT), so it needs its own context and callbacks. Add in `prometheus_exporter.cc`: + +```cpp +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 + const char *prom_type = "gauge"; + if (mc->current_type == "counter") { + prom_type = "counter"; + } + // value, status_counter, set_owner, set_member -> gauge + + 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); + switch (mc->col_index) { + case 0: + mc->current_name.assign(value, length); + break; + case 1: + // SUBSYSTEM -- skip, not used + break; + case 2: + mc->current_type.assign(value, length); + break; + case 3: + mc->current_count.assign(value, length); + break; + } + mc->col_index++; + return 0; +} + +static void innodb_handle_error(void *ctx, uint, const char *, const char *) { + static_cast(ctx)->error = true; +} + +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, +}; +``` + +- [ ] **Step 3: Add the collector function** + +```cpp +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); +} +``` + +- [ ] **Step 4: Wire into collect_metrics()** + +Add after `collect_global_variables(session, output);`: + +```cpp + collect_innodb_metrics(session, output); +``` + +- [ ] **Step 5: Build** + +```bash +cd /data/rene/build && make -j32 prometheus_exporter +``` + +Expected: builds with no errors. + +- [ ] **Step 6: Record and run test** + +```bash +cd /data/rene/build && ./mysql-test/mysql-test-run.pl --record --suite=prometheus_exporter --nounit-tests innodb_metrics +``` + +Expected: PASS. Note: the exact metric names depend on which InnoDB metrics are enabled by default. The test greps for known ones. If a specific metric name doesn't exist, adjust the grep to match an existing metric from the output. + +- [ ] **Step 7: Run all tests** + +```bash +cd /data/rene/build && ./mysql-test/mysql-test-run.pl --suite=prometheus_exporter --nounit-tests --parallel=auto +``` + +Expected: All tests pass. + +- [ ] **Step 8: Commit** + +```bash +git add plugin/prometheus_exporter/prometheus_exporter.cc \ + mysql-test/suite/prometheus_exporter/t/innodb_metrics.test \ + mysql-test/suite/prometheus_exporter/t/innodb_metrics-master.opt \ + mysql-test/suite/prometheus_exporter/r/innodb_metrics.result +git commit -m "feat(prometheus): add INNODB_METRICS collector + +Exports ~200 detailed InnoDB metrics from information_schema.INNODB_METRICS +as mysql_innodb_metrics_* with type mapping from InnoDB's own TYPE column +(counter -> counter, value/status_counter/set_owner/set_member -> gauge)." +``` + +--- + +## Task 4: Add SHOW REPLICA STATUS collector + +**Files:** +- Modify: `plugin/prometheus_exporter/prometheus_exporter.cc` +- Create: `mysql-test/suite/prometheus_exporter/t/replica_status.test` +- Create: `mysql-test/suite/prometheus_exporter/t/replica_status-master.opt` +- Create: `mysql-test/suite/prometheus_exporter/r/replica_status.result` + +- [ ] **Step 1: Write the test** + +This test verifies graceful absence -- on a non-replica server, no `mysql_replica_` metrics should appear. + +Create `mysql-test/suite/prometheus_exporter/t/replica_status-master.opt`: +``` +$PROMETHEUS_EXPORTER_PLUGIN_OPT $PROMETHEUS_EXPORTER_PLUGIN_LOAD --prometheus-exporter-enabled=ON --prometheus-exporter-port=19107 --prometheus-exporter-bind-address=127.0.0.1 +``` + +Create `mysql-test/suite/prometheus_exporter/t/replica_status.test`: +```sql +--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_" || echo "0" + +--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 +``` + +- [ ] **Step 2: Add ReplicaStatusCtx and callbacks** + +This collector needs column-name-aware parsing. During `field_metadata`, build a column name list. During `get_string`, map column index to field name and collect wanted fields. + +```cpp +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.emplace_back(field->col_name); + return 0; +} + +static int replica_start_row(void *ctx) { + auto *rc = static_cast(ctx); + rc->col_index = 0; + rc->col_values.clear(); + rc->col_values.resize(rc->col_names.size()); + 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; +} + +static int replica_end_row(void *ctx) { + auto *rc = static_cast(ctx); + + struct ReplicaField { + const char *mysql_name; + const char *prom_name; + bool is_bool; // Yes/No -> 1/0 + }; + + static const ReplicaField 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}, + {nullptr, nullptr, false}, + }; + + for (size_t i = 0; i < rc->col_names.size(); i++) { + for (const ReplicaField *f = wanted_fields; f->mysql_name != nullptr; f++) { + if (strcasecmp(rc->col_names[i].c_str(), f->mysql_name) != 0) continue; + if (rc->col_values[i].empty()) continue; + + std::string val; + if (f->is_bool) { + val = (rc->col_values[i] == "Yes") ? "1" : "0"; + } else { + char *end = nullptr; + strtod(rc->col_values[i].c_str(), &end); + if (end == rc->col_values[i].c_str()) continue; // skip non-numeric + val = rc->col_values[i]; + } + + *rc->output += "# TYPE "; + *rc->output += f->prom_name; + *rc->output += " gauge\n"; + *rc->output += f->prom_name; + *rc->output += ' '; + *rc->output += val; + *rc->output += '\n'; + } + } + + return 0; +} + +static void replica_handle_error(void *ctx, uint, const char *, const char *) { + static_cast(ctx)->error = true; +} + +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, +}; +``` + +Note: add `#include ` to the includes at the top of the file. + +- [ ] **Step 3: Add the collector function** + +```cpp +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); +} +``` + +- [ ] **Step 4: Wire into collect_metrics()** + +Add after `collect_innodb_metrics(session, output);`: + +```cpp + collect_replica_status(session, output); +``` + +- [ ] **Step 5: Build** + +```bash +cd /data/rene/build && make -j32 prometheus_exporter +``` + +Expected: builds with no errors. + +- [ ] **Step 6: Record and run test** + +```bash +cd /data/rene/build && ./mysql-test/mysql-test-run.pl --record --suite=prometheus_exporter --nounit-tests replica_status +``` + +Expected: PASS. The test verifies that on a non-replica, `mysql_replica_` metrics are absent (grep returns count 0). + +- [ ] **Step 7: Run all tests** + +```bash +cd /data/rene/build && ./mysql-test/mysql-test-run.pl --suite=prometheus_exporter --nounit-tests --parallel=auto +``` + +Expected: All tests pass. + +- [ ] **Step 8: Commit** + +```bash +git add plugin/prometheus_exporter/prometheus_exporter.cc \ + mysql-test/suite/prometheus_exporter/t/replica_status.test \ + mysql-test/suite/prometheus_exporter/t/replica_status-master.opt \ + mysql-test/suite/prometheus_exporter/r/replica_status.result +git commit -m "feat(prometheus): add SHOW REPLICA STATUS collector + +Exports replication metrics (seconds_behind_source, io_running, +sql_running, relay_log_space, log positions) as mysql_replica_* gauges. +Gracefully skipped when server is not a replica (no rows returned)." +``` + +--- + +## Task 5: Add SHOW BINARY LOGS collector + +**Files:** +- Modify: `plugin/prometheus_exporter/prometheus_exporter.cc` +- Create: `mysql-test/suite/prometheus_exporter/t/binlog.test` +- Create: `mysql-test/suite/prometheus_exporter/t/binlog-master.opt` +- Create: `mysql-test/suite/prometheus_exporter/r/binlog.result` + +- [ ] **Step 1: Write the test** + +Create `mysql-test/suite/prometheus_exporter/t/binlog-master.opt`: +``` +$PROMETHEUS_EXPORTER_PLUGIN_OPT $PROMETHEUS_EXPORTER_PLUGIN_LOAD --prometheus-exporter-enabled=ON --prometheus-exporter-port=19108 --prometheus-exporter-bind-address=127.0.0.1 +``` + +Create `mysql-test/suite/prometheus_exporter/t/binlog.test`: +```sql +--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 +``` + +- [ ] **Step 2: Add BinlogCtx and callbacks** + +```cpp +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()) { + char *end = nullptr; + long long sz = strtoll(bc->current_size.c_str(), &end, 10); + if (end != bc->current_size.c_str()) { + 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) { // File_size column + 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 *) { + static_cast(ctx)->error = true; +} + +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, +}; +``` + +- [ ] **Step 3: Add the collector function** + +```cpp +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); +} +``` + +- [ ] **Step 4: Wire into collect_metrics()** + +Add after `collect_replica_status(session, output);`: + +```cpp + collect_binlog(session, output); +``` + +- [ ] **Step 5: Build** + +```bash +cd /data/rene/build && make -j32 prometheus_exporter +``` + +Expected: builds with no errors. + +- [ ] **Step 6: Record and run test** + +```bash +cd /data/rene/build && ./mysql-test/mysql-test-run.pl --record --suite=prometheus_exporter --nounit-tests binlog +``` + +Expected: PASS. Binary logging is on by default in MTR, so binlog metrics should appear. + +- [ ] **Step 7: Run all tests** + +```bash +cd /data/rene/build && ./mysql-test/mysql-test-run.pl --suite=prometheus_exporter --nounit-tests --parallel=auto +``` + +Expected: All tests pass. + +- [ ] **Step 8: Commit** + +```bash +git add plugin/prometheus_exporter/prometheus_exporter.cc \ + mysql-test/suite/prometheus_exporter/t/binlog.test \ + mysql-test/suite/prometheus_exporter/t/binlog-master.opt \ + mysql-test/suite/prometheus_exporter/r/binlog.result +git commit -m "feat(prometheus): add SHOW BINARY LOGS collector + +Exports mysql_binlog_file_count and mysql_binlog_size_bytes_total +as gauge metrics. Silently skipped when binary logging is disabled." +``` + +--- + +## Task 6: Update existing tests and add format validation + +**Files:** +- Modify: `mysql-test/suite/prometheus_exporter/t/basic.test` +- Modify: `mysql-test/suite/prometheus_exporter/t/metrics_endpoint.test` +- Create: `mysql-test/suite/prometheus_exporter/t/format_validation.test` +- Create: `mysql-test/suite/prometheus_exporter/t/format_validation-master.opt` +- Create: `mysql-test/suite/prometheus_exporter/r/format_validation.result` +- Modify/record: `mysql-test/suite/prometheus_exporter/r/basic.result` +- Modify/record: `mysql-test/suite/prometheus_exporter/r/metrics_endpoint.result` + +- [ ] **Step 1: Add inline comments to basic.test** + +Replace the content of `mysql-test/suite/prometheus_exporter/t/basic.test`: + +```sql +# ============================================================================= +# 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; +``` + +- [ ] **Step 2: Update metrics_endpoint.test to verify all 5 prefixes** + +Replace the content of `mysql-test/suite/prometheus_exporter/t/metrics_endpoint.test`: + +```sql +# ============================================================================= +# metrics_endpoint.test -- Prometheus Exporter: HTTP Endpoint & All Collectors +# +# Verifies: +# - HTTP endpoint serves Prometheus text format +# - All 5 collector prefixes appear in output +# - 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'; +``` + +- [ ] **Step 3: Create format_validation test** + +Create `mysql-test/suite/prometheus_exporter/t/format_validation-master.opt`: +``` +$PROMETHEUS_EXPORTER_PLUGIN_OPT $PROMETHEUS_EXPORTER_PLUGIN_LOAD --prometheus-exporter-enabled=ON --prometheus-exporter-port=19109 --prometheus-exporter-bind-address=127.0.0.1 +``` + +Create `mysql-test/suite/prometheus_exporter/t/format_validation.test`: +```sql +# ============================================================================= +# 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_]* +# - Values are numeric +# ============================================================================= + +--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 ($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 + unless ($value =~ /^-?[0-9]+(\.[0-9]+)?([eE][+-]?[0-9]+)?$/) { + print "FORMAT ERROR: non-numeric value '$value' for metric '$name'\n"; + $errors++; + } + } else { + print "FORMAT ERROR: unrecognized line: $line\n"; + $errors++; + } +} + +if ($errors == 0 && $metrics_count > 0) { + print "OK: $metrics_count metrics validated, 0 format errors\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 +``` + +- [ ] **Step 4: Record all test results** + +```bash +cd /data/rene/build && ./mysql-test/mysql-test-run.pl --record --suite=prometheus_exporter --nounit-tests basic +cd /data/rene/build && ./mysql-test/mysql-test-run.pl --record --suite=prometheus_exporter --nounit-tests metrics_endpoint +cd /data/rene/build && ./mysql-test/mysql-test-run.pl --record --suite=prometheus_exporter --nounit-tests format_validation +``` + +Expected: All three PASS and result files are generated/updated. + +- [ ] **Step 5: Run all tests together** + +```bash +cd /data/rene/build && ./mysql-test/mysql-test-run.pl --suite=prometheus_exporter --nounit-tests --parallel=auto +``` + +Expected: All 8 tests pass (basic, metrics_endpoint, global_variables, innodb_metrics, replica_status, binlog, format_validation, shutdown_report). + +- [ ] **Step 6: Commit** + +```bash +git add mysql-test/suite/prometheus_exporter/ +git commit -m "test(prometheus): expand test suite with format validation + +- Add inline documentation to basic.test +- Update metrics_endpoint.test to verify all 5 collector prefixes +- Add format_validation.test: perl-based structural validation of + Prometheus exposition format (valid TYPE lines, matching metric names, + numeric values) +Total: 7 tests covering all collectors and output format correctness." +``` + +--- + +## Task 7: Create documentation + +**Files:** +- Create: `plugin/prometheus_exporter/README.md` +- Create: `Docs/prometheus_exporter.md` + +- [ ] **Step 1: Create the full README** + +Create `plugin/prometheus_exporter/README.md` with the following content: + +```markdown +# 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. + +```text +┌─────────────────────────────────────────────────────┐ +│ 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 | 0.0.0.0 | 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. +``` + +- [ ] **Step 2: Create the Docs pointer** + +Create `Docs/prometheus_exporter.md`: + +```markdown +# 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). +``` + +- [ ] **Step 3: Commit** + +```bash +git add plugin/prometheus_exporter/README.md Docs/prometheus_exporter.md +git commit -m "docs(prometheus): add architecture docs and configuration reference + +- Full README with ASCII architecture diagram, configuration table, + metric namespace reference, usage examples, type classification, + and limitations +- Pointer doc in Docs/ directory" +``` + +--- + +## Self-Review Checklist + +**Spec coverage:** +- [x] SHOW GLOBAL STATUS (existing, refactored in Task 1) +- [x] SHOW GLOBAL VARIABLES (Task 2) +- [x] INNODB_METRICS (Task 3) +- [x] SHOW REPLICA STATUS (Task 4) +- [x] SHOW BINARY LOGS (Task 5) +- [x] Test expansion to 7 tests (Task 6) +- [x] Format validation test (Task 6) +- [x] Documentation with architecture diagram (Task 7) +- [x] Docs/ pointer (Task 7) +- [x] Port allocation for parallel safety (each .opt file uses different port) + +**Placeholder scan:** No TBD/TODO/placeholder text found. + +**Type consistency:** +- `MetricsCollectorCtx` used consistently across Tasks 1-2 +- `InnodbMetricsCtx` defined and used only in Task 3 +- `ReplicaStatusCtx` defined and used only in Task 4 +- `BinlogCtx` defined and used only in Task 5 +- `collect_*` function signatures consistent: `(MYSQL_SESSION, std::string &)` +- All callback structs follow same pattern: reuse no-op callbacks, specialize only what differs diff --git a/docs/superpowers/specs/2026-04-05-prometheus-exporter-v2-design.md b/docs/superpowers/specs/2026-04-05-prometheus-exporter-v2-design.md new file mode 100644 index 000000000000..ff5fe7c7dad5 --- /dev/null +++ b/docs/superpowers/specs/2026-04-05-prometheus-exporter-v2-design.md @@ -0,0 +1,241 @@ +# Prometheus Exporter Plugin v2 -- Design Spec + +## Summary + +Expand the embedded Prometheus exporter plugin from a single data source (`SHOW GLOBAL STATUS`) to five data sources, add comprehensive test coverage with format validation, and create full documentation with architecture diagrams. + +## Current State (v1) + +- Single-file daemon plugin at `plugin/prometheus_exporter/prometheus_exporter.cc` (~573 lines) +- Exports `SHOW GLOBAL STATUS` variables as `mysql_global_status_*` metrics +- Bare-bones HTTP server on configurable port (default 9104), disabled by default +- Uses `srv_session` + `command_service_run_command` to execute SQL internally (no network, no auth -- pure in-process function calls) +- 2 MTR tests: `basic` (install/uninstall) and `metrics_endpoint` (HTTP format validation) +- System variables: `enabled`, `port`, `bind_address` (all READONLY) +- Plugin status variables: `requests_total`, `errors_total`, `scrape_duration_microseconds` + +## Design Decisions + +### SQL queries over direct internal access + +The plugin executes SQL queries via the `srv_session` service rather than accessing internal server structs directly. Rationale: VillageSQL rebases onto new MySQL versions; internal struct layouts change across versions, but SQL interfaces (`SHOW GLOBAL STATUS`, `information_schema` tables) are stable. The per-scrape overhead of SQL execution (~milliseconds) is negligible since Prometheus scrapes every 15-60 seconds. + +### Single file + +The plugin stays in a single `.cc` file. With the v2 additions it will be ~800-900 lines. This is manageable for a self-contained plugin and avoids header management overhead. Can be split later if it grows further. + +### Session reuse within a scrape + +All collectors run on the same `srv_session` -- opened once per scrape, closed after all collectors finish. This avoids per-query session creation overhead. + +### Graceful failure per collector + +Each collector silently skips on error. For example, `SHOW REPLICA STATUS` returns no rows on a non-replica server; `SHOW BINARY LOGS` fails if binary logging is disabled. The scrape still succeeds with whatever metrics were collected. + +## Expanded Metrics: 5 Data Sources + +### 1. SHOW GLOBAL STATUS (existing) + +- **Query**: `SHOW GLOBAL STATUS` +- **Prefix**: `mysql_global_status_` +- **Type logic**: Known gauge list (Threads_connected, Open_tables, buffer pool pages, Uptime, etc.); everything else `untyped` +- **Callback**: 2-column (Variable_name, Value), skip non-numeric values + +### 2. SHOW GLOBAL VARIABLES (new) + +- **Query**: `SHOW GLOBAL VARIABLES` +- **Prefix**: `mysql_global_variables_` +- **Type logic**: All `gauge` (configuration values are point-in-time snapshots) +- **Callback**: Same 2-column pattern as Global Status, skip non-numeric values (strings like `datadir` are discarded) +- **Key metrics exposed**: `max_connections`, `innodb_buffer_pool_size`, `innodb_log_file_size`, `table_open_cache`, `thread_cache_size`, etc. + +### 3. information_schema.INNODB_METRICS (new) + +- **Query**: `SELECT NAME, SUBSYSTEM, TYPE, COUNT FROM information_schema.INNODB_METRICS WHERE STATUS='enabled'` +- **Prefix**: `mysql_innodb_metrics_` +- **Type logic**: Uses InnoDB's own `TYPE` column: + - `counter` -> Prometheus `counter` + - `value`, `status_counter`, `set_owner`, `set_member` -> Prometheus `gauge` +- **Callback**: 4-column (NAME, SUBSYSTEM, TYPE, COUNT). Subsystem is not emitted as a label (to avoid cardinality); it's implicit in metric names. +- **Key metrics exposed**: ~200 detailed InnoDB internals covering buffer pool, transactions, locks, redo log, purge, DML operations, adaptive hash index, etc. This covers the quantitative data from `SHOW ENGINE INNODB STATUS` without text parsing. + +### 4. SHOW REPLICA STATUS (new) + +- **Query**: `SHOW REPLICA STATUS` +- **Prefix**: `mysql_replica_` +- **Type logic**: Per-field mapping +- **Callback**: Column-name-aware. During `field_metadata`, capture column names into a vector. During `get_string`, match against wanted fields. +- **Fields exported**: + +| MySQL Column | Prometheus Metric | Type | +|---|---|---| +| `Seconds_Behind_Source` | `mysql_replica_seconds_behind_source` | gauge | +| `Replica_IO_Running` | `mysql_replica_io_running` | gauge (1=Yes, 0=No) | +| `Replica_SQL_Running` | `mysql_replica_sql_running` | gauge (1=Yes, 0=No) | +| `Relay_Log_Space` | `mysql_replica_relay_log_space` | gauge | +| `Exec_Source_Log_Pos` | `mysql_replica_exec_source_log_pos` | gauge | +| `Read_Source_Log_Pos` | `mysql_replica_read_source_log_pos` | gauge | + +- If the query returns no rows (server is not a replica), nothing is emitted. + +### 5. SHOW BINARY LOGS (new) + +- **Query**: `SHOW BINARY LOGS` +- **Prefix**: `mysql_binlog_` +- **Type logic**: All `gauge` +- **Callback**: Accumulates across all result rows. Counts rows and sums `File_size` column. +- **Metrics exported**: + - `mysql_binlog_file_count` -- number of binary log files + - `mysql_binlog_size_bytes_total` -- total size of all binary log files +- If binary logging is disabled, query fails silently -- no metrics emitted. + +## Code Organization + +All changes within `plugin/prometheus_exporter/prometheus_exporter.cc`. Internal structure: + +``` +1. Includes, logging refs, system vars, context struct (existing) +2. Prometheus formatting helpers + gauge classification (existing, expanded) +3. Command service callbacks (reusable) (existing, refactored) +4. Collector: collect_global_status() (refactored from existing) +5. Collector: collect_global_variables() (new) +6. Collector: collect_innodb_metrics() (new) +7. Collector: collect_replica_status() (new) +8. Collector: collect_binlog() (new) +9. collect_metrics() orchestrator (refactored) +10. HTTP server (existing) +11. Plugin init/deinit, status vars, declaration (existing) +``` + +### Collector function signature + +```cpp +static void collect_(MYSQL_SESSION session, std::string &output); +``` + +Each collector takes the already-open session, runs its query, appends Prometheus-formatted lines to `output`. Returns silently on any error. + +### Callback reuse + +- **Global Status, Global Variables**: Reuse existing `MetricsCollectorCtx` and `prom_cbs` callbacks with configurable prefix and type-determination function passed via context. +- **InnoDB Metrics**: New context struct for 4-column results (NAME, SUBSYSTEM, TYPE, COUNT). +- **Replica Status**: New context struct with column-name-to-index mapping built during `field_metadata`. +- **Binary Logs**: New context struct that accumulates file count and total size. + +## Tests + +### Test inventory (7 tests) + +| Test | Purpose | .opt file needed | +|------|---------|:---:| +| `basic` | Install/uninstall, system vars, status vars. Add inline comments. | No | +| `metrics_endpoint` | HTTP endpoint, expanded to verify all 5 data source prefixes appear | Yes | +| `global_variables` | Verify `mysql_global_variables_max_connections` appears with type `gauge` | Yes | +| `innodb_metrics` | Verify `mysql_innodb_metrics_` lines appear with correct counter/gauge types | Yes | +| `replica_status` | Verify graceful absence: no `mysql_replica_` metrics on non-replica server | Yes | +| `binlog` | Verify `mysql_binlog_file_count` and `mysql_binlog_size_bytes_total` appear | Yes | +| `format_validation` | Perl block validates entire `/metrics` output structure (see below) | Yes | + +### Format validation test + +A perl block fetches the full `/metrics` output via curl and validates: +- Every `# TYPE ` line has `` in {counter, gauge, untyped} +- Every `# TYPE` line is immediately followed by a metric line starting with the same `` +- Every metric value line has format ` ` +- Metric names match `[a-z_][a-z0-9_]*` +- No blank values, no trailing whitespace on metric lines + +### Port allocation for parallel test safety + +Each test with a `.opt` file uses a different port to avoid conflicts when MTR runs tests in parallel: +- `metrics_endpoint`: 19104 +- `global_variables`: 19105 +- `innodb_metrics`: 19106 +- `replica_status`: 19107 +- `binlog`: 19108 +- `format_validation`: 19109 + +## Documentation + +### `plugin/prometheus_exporter/README.md` (full docs) + +1. **Overview** -- what the plugin is, philosophy (embedded, no sidecar) +2. **Architecture diagram** -- ASCII art: + +``` +┌─────────────────────────────────────────────────────┐ +│ 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 + │ + ┌────┴─────┐ + │Prometheus│ + │ Server │ + └──────────┘ +``` + +3. **Configuration** -- table of system variables (enabled, port, bind_address) with defaults and descriptions +4. **Metric namespaces** -- table of 5 prefixes, their source queries, and type classification logic +5. **Usage** -- `INSTALL PLUGIN` vs `--plugin-load`, example curl output snippet +6. **Metric type classification** -- how gauge/counter/untyped is determined per source +7. **Plugin status variables** -- the 3 self-monitoring metrics +8. **Limitations** -- no TLS, no auth (rely on bind_address), single-threaded scrape handling, Linux-only (POSIX sockets) + +### `Docs/prometheus_exporter.md` (pointer) + +Brief file pointing to the full docs: + +```markdown +# 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). +``` + +## Files Modified/Created + +| File | Action | +|------|--------| +| `plugin/prometheus_exporter/prometheus_exporter.cc` | Modified -- add 4 collectors, refactor collect_metrics(), expand gauge list | +| `plugin/prometheus_exporter/README.md` | Created -- full documentation with architecture diagram | +| `Docs/prometheus_exporter.md` | Created -- pointer to plugin docs | +| `mysql-test/suite/prometheus_exporter/t/basic.test` | Modified -- add inline comments | +| `mysql-test/suite/prometheus_exporter/t/metrics_endpoint.test` | Modified -- verify all 5 prefixes | +| `mysql-test/suite/prometheus_exporter/t/global_variables.test` | Created | +| `mysql-test/suite/prometheus_exporter/t/innodb_metrics.test` | Created | +| `mysql-test/suite/prometheus_exporter/t/replica_status.test` | Created | +| `mysql-test/suite/prometheus_exporter/t/binlog.test` | Created | +| `mysql-test/suite/prometheus_exporter/t/format_validation.test` | Created | +| `mysql-test/suite/prometheus_exporter/r/*.result` | Created/updated for all tests | +| `mysql-test/suite/prometheus_exporter/t/*-master.opt` | Created for new tests | + +No changes to any files outside `plugin/prometheus_exporter/`, `Docs/`, and `mysql-test/suite/prometheus_exporter/`. 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..aa13a185dc1c --- /dev/null +++ b/mysql-test/suite/prometheus_exporter/r/basic.result @@ -0,0 +1,16 @@ +# Install prometheus_exporter plugin +INSTALL PLUGIN prometheus_exporter SONAME 'PROMETHEUS_EXPORTER_PLUGIN'; +# Verify system variables exist +SHOW VARIABLES LIKE 'prometheus_exporter%'; +Variable_name Value +prometheus_exporter_bind_address 0.0.0.0 +prometheus_exporter_enabled OFF +prometheus_exporter_port 9104 +# Verify status variables exist +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/metrics_endpoint.result b/mysql-test/suite/prometheus_exporter/r/metrics_endpoint.result new file mode 100644 index 000000000000..6133a1b7a5f2 --- /dev/null +++ b/mysql-test/suite/prometheus_exporter/r/metrics_endpoint.result @@ -0,0 +1,16 @@ +SHOW VARIABLES LIKE 'prometheus_exporter_enabled'; +Variable_name Value +prometheus_exporter_enabled ON +# Test /metrics endpoint returns Prometheus-formatted data +# Check that TYPE annotations and metric lines are present +# TYPE mysql_global_status_threads_connected gauge +# Check that a counter metric exists +# TYPE mysql_global_status_questions untyped +# Check that an actual metric value line exists +mysql_global_status_threads_connected NUM +# Test 404 for unknown paths +404 +# Verify scrape counter incremented (at least 2 scrapes above) +SHOW STATUS LIKE 'Prometheus_exporter_requests_total'; +Variable_name Value +Prometheus_exporter_requests_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..e827aaa77cf5 --- /dev/null +++ b/mysql-test/suite/prometheus_exporter/t/basic.test @@ -0,0 +1,24 @@ +--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 +SHOW VARIABLES LIKE 'prometheus_exporter%'; + +--echo # Verify status variables exist +SHOW STATUS LIKE 'Prometheus_exporter%'; + +--echo # Uninstall plugin +UNINSTALL PLUGIN prometheus_exporter; 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..dc6620ea8c6c --- /dev/null +++ b/mysql-test/suite/prometheus_exporter/t/metrics_endpoint.test @@ -0,0 +1,22 @@ +--source include/not_windows.inc + +# Verify plugin is loaded and enabled +SHOW VARIABLES LIKE 'prometheus_exporter_enabled'; + +--echo # Test /metrics endpoint returns Prometheus-formatted data +--echo # Check that TYPE annotations and metric lines are present +--exec curl -s http://127.0.0.1:19104/metrics | grep "^# TYPE mysql_global_status_threads_connected" | head -1 + +--echo # Check that a counter metric exists +--exec curl -s http://127.0.0.1:19104/metrics | grep "^# TYPE mysql_global_status_questions" | head -1 + +--echo # Check that an actual metric value line exists +--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 (at least 2 scrapes above) +--replace_regex /[0-9]+/NUM/ +SHOW STATUS LIKE 'Prometheus_exporter_requests_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/prometheus_exporter.cc b/plugin/prometheus_exporter/prometheus_exporter.cc new file mode 100644 index 000000000000..0d2aeacd9c19 --- /dev/null +++ b/plugin/prometheus_exporter/prometheus_exporter.cc @@ -0,0 +1,572 @@ +// 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 + +// ----------------------------------------------------------------------- +// Logging service references +// ----------------------------------------------------------------------- + +static SERVICE_TYPE(registry) *reg_srv = nullptr; +SERVICE_TYPE(log_builtins) *log_bi = nullptr; +SERVICE_TYPE(log_builtins_string) *log_bs = nullptr; + +// ----------------------------------------------------------------------- +// System variables +// ----------------------------------------------------------------------- + +static bool prom_enabled = false; +static unsigned int prom_port = 9104; +static char *prom_bind_address = 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 0.0.0.0.", + nullptr, nullptr, "0.0.0.0"); + +static SYS_VAR *prom_system_vars[] = { + MYSQL_SYSVAR(enabled), + MYSQL_SYSVAR(port), + MYSQL_SYSVAR(bind_address), + nullptr, +}; + +// ----------------------------------------------------------------------- +// Plugin context +// ----------------------------------------------------------------------- + +struct PrometheusContext { + my_thread_handle listener_thread; + int listen_fd; + std::atomic shutdown_requested; + void *plugin_ref; + + std::atomic requests_total; + std::atomic errors_total; + std::atomic last_scrape_duration_us; + + PrometheusContext() + : listen_fd(-1), + shutdown_requested(false), + plugin_ref(nullptr), + requests_total(0), + errors_total(0), + last_scrape_duration_us(0) {} +}; + +static PrometheusContext *g_ctx = nullptr; + +// ----------------------------------------------------------------------- +// Gauge variable classification +// ----------------------------------------------------------------------- + +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; +} + +// ----------------------------------------------------------------------- +// Prometheus metric name conversion +// ----------------------------------------------------------------------- + +static std::string to_prometheus_name(const char *mysql_name) { + std::string result = "mysql_global_status_"; + for (const char *p = mysql_name; *p != '\0'; ++p) { + result += static_cast(tolower(static_cast(*p))); + } + return result; +} + +// ----------------------------------------------------------------------- +// Metrics collection via srv_session + command_service +// ----------------------------------------------------------------------- + +struct MetricsCollectorCtx { + std::string output; + std::string current_name; + std::string current_value; + int col_index; + bool error; +}; + +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; + double val = strtod(mc->current_value.c_str(), &end); + if (end == mc->current_value.c_str()) return 0; // not numeric + (void)val; // we use the string representation directly + + std::string prom_name = to_prometheus_name(mc->current_name.c_str()); + const char *type_str = is_gauge(mc->current_name.c_str()) ? "gauge" : "untyped"; + + 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; +} + +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 +}; + +static std::string collect_metrics() { + if (!srv_session_server_is_available()) { + return "# Server not available\n"; + } + + if (srv_session_init_thread(g_ctx->plugin_ref) != 0) { + return "# Failed to init session thread\n"; + } + + MYSQL_SESSION session = srv_session_open(nullptr, nullptr); + if (session == nullptr) { + srv_session_deinit_thread(); + return "# Failed to open session\n"; + } + + // Switch to root security context + MYSQL_SECURITY_CONTEXT sc; + thd_get_security_context(srv_session_info_get_thd(session), &sc); + security_context_lookup(sc, "root", "localhost", "127.0.0.1", ""); + + MetricsCollectorCtx mc; + mc.col_index = 0; + mc.error = false; + + COM_DATA cmd; + memset(&cmd, 0, sizeof(cmd)); + cmd.com_query.query = "SHOW GLOBAL STATUS"; + cmd.com_query.length = strlen(cmd.com_query.query); + + command_service_run_command(session, COM_QUERY, &cmd, + &my_charset_utf8mb3_general_ci, &prom_cbs, + CS_TEXT_REPRESENTATION, &mc); + + srv_session_close(session); + srv_session_deinit_thread(); + + if (mc.error) { + return "# Error collecting metrics\n"; + } + + return mc.output; +} + +// ----------------------------------------------------------------------- +// HTTP server +// ----------------------------------------------------------------------- + +static int setup_listen_socket(const char *bind_addr, unsigned int port) { + 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 = write(fd, buf + written, len - written); + if (n <= 0) break; + written += static_cast(n); + } +} + +static void *prometheus_listener_thread(void *arg) { + auto *ctx = static_cast(arg); + + while (!ctx->shutdown_requested.load(std::memory_order_relaxed)) { + struct pollfd pfd; + pfd.fd = ctx->listen_fd; + pfd.events = POLLIN; + + int ret = poll(&pfd, 1, 1000); + if (ret <= 0) continue; + + int client_fd = accept(ctx->listen_fd, nullptr, nullptr); + if (client_fd < 0) continue; + + // Read HTTP request + char buf[4096]; + ssize_t n = read(client_fd, buf, sizeof(buf) - 1); + if (n <= 0) { + close(client_fd); + continue; + } + buf[n] = '\0'; + + if (strncmp(buf, "GET /metrics", 12) == 0) { + ctx->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); + ctx->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); + } + + return nullptr; +} + +// ----------------------------------------------------------------------- +// Plugin init / deinit +// ----------------------------------------------------------------------- + +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; + } + + g_ctx = new (std::nothrow) PrometheusContext(); + if (g_ctx == nullptr) return 1; + g_ctx->plugin_ref = p; + + g_ctx->listen_fd = setup_listen_socket(prom_bind_address, prom_port); + if (g_ctx->listen_fd < 0) { + LogPluginErr(ERROR_LEVEL, ER_LOG_PRINTF_MSG, + "Prometheus exporter: failed to bind to %s:%u", + prom_bind_address, prom_port); + delete g_ctx; + g_ctx = nullptr; + 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(&g_ctx->listener_thread, &attr, + prometheus_listener_thread, g_ctx) != 0) { + LogPluginErr(ERROR_LEVEL, ER_LOG_PRINTF_MSG, + "Prometheus exporter: failed to create listener thread"); + close(g_ctx->listen_fd); + delete g_ctx; + g_ctx = nullptr; + return 1; + } + + LogPluginErr(INFORMATION_LEVEL, ER_LOG_PRINTF_MSG, + "Prometheus exporter listening on %s:%u", prom_bind_address, + prom_port); + + plugin->data = g_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_relaxed); + if (ctx->listen_fd >= 0) close(ctx->listen_fd); + + void *dummy; + my_thread_join(&ctx->listener_thread, &dummy); + + delete ctx; + g_ctx = nullptr; + } + + deinit_logging_service_for_plugin(®_srv, &log_bi, &log_bs); + return 0; +} + +// ----------------------------------------------------------------------- +// Plugin status variables +// ----------------------------------------------------------------------- + +static int show_requests_total(MYSQL_THD, SHOW_VAR *var, char *buff) { + var->type = SHOW_LONGLONG; + var->value = buff; + *reinterpret_cast(buff) = + g_ctx != nullptr + ? static_cast(g_ctx->requests_total.load( + std::memory_order_relaxed)) + : 0; + return 0; +} + +static int show_errors_total(MYSQL_THD, SHOW_VAR *var, char *buff) { + var->type = SHOW_LONGLONG; + var->value = buff; + *reinterpret_cast(buff) = + g_ctx != nullptr ? static_cast( + g_ctx->errors_total.load(std::memory_order_relaxed)) + : 0; + return 0; +} + +static int show_scrape_duration(MYSQL_THD, SHOW_VAR *var, char *buff) { + var->type = SHOW_LONGLONG; + var->value = buff; + *reinterpret_cast(buff) = + g_ctx != nullptr + ? static_cast(g_ctx->last_scrape_duration_us.load( + std::memory_order_relaxed)) + : 0; + 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}, +}; + +// ----------------------------------------------------------------------- +// Plugin declaration +// ----------------------------------------------------------------------- + +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; From 37d120a5df920d6d5e7cf716b0b99a9bd1d33cf9 Mon Sep 17 00:00:00 2001 From: Rene Cannao Date: Sun, 5 Apr 2026 05:39:24 +0000 Subject: [PATCH 02/16] Refactor prometheus exporter into collector pattern Extract the inline SHOW GLOBAL STATUS logic from collect_metrics() into separate reusable functions: collect_name_value_query() as a generic helper for 2-column queries, and collect_global_status() as the specific collector. MetricsCollectorCtx now takes a configurable prefix and type function, enabling additional collectors to be added cleanly. --- .../prometheus_exporter.cc | 95 +++++++++++-------- 1 file changed, 53 insertions(+), 42 deletions(-) diff --git a/plugin/prometheus_exporter/prometheus_exporter.cc b/plugin/prometheus_exporter/prometheus_exporter.cc index 0d2aeacd9c19..a7518b7bc3b6 100644 --- a/plugin/prometheus_exporter/prometheus_exporter.cc +++ b/plugin/prometheus_exporter/prometheus_exporter.cc @@ -155,30 +155,26 @@ static bool is_gauge(const char *name) { return false; } -// ----------------------------------------------------------------------- -// Prometheus metric name conversion -// ----------------------------------------------------------------------- - -static std::string to_prometheus_name(const char *mysql_name) { - std::string result = "mysql_global_status_"; - for (const char *p = mysql_name; *p != '\0'; ++p) { - result += static_cast(tolower(static_cast(*p))); - } - return result; -} - // ----------------------------------------------------------------------- // Metrics collection via srv_session + command_service // ----------------------------------------------------------------------- +typedef const char *(*type_fn_t)(const char *name); + struct MetricsCollectorCtx { - std::string output; + 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; @@ -210,18 +206,22 @@ static int prom_end_row(void *ctx) { if (end == mc->current_value.c_str()) return 0; // not numeric (void)val; // we use the string representation directly - std::string prom_name = to_prometheus_name(mc->current_name.c_str()); - const char *type_str = is_gauge(mc->current_name.c_str()) ? "gauge" : "untyped"; - - 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'; + // 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; } @@ -284,6 +284,31 @@ static const struct st_command_service_cbs prom_cbs = { nullptr, // connection_alive }; +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 std::string collect_metrics() { if (!srv_session_server_is_available()) { return "# Server not available\n"; @@ -304,27 +329,13 @@ static std::string collect_metrics() { thd_get_security_context(srv_session_info_get_thd(session), &sc); security_context_lookup(sc, "root", "localhost", "127.0.0.1", ""); - MetricsCollectorCtx mc; - mc.col_index = 0; - mc.error = false; - - COM_DATA cmd; - memset(&cmd, 0, sizeof(cmd)); - cmd.com_query.query = "SHOW GLOBAL STATUS"; - cmd.com_query.length = strlen(cmd.com_query.query); - - command_service_run_command(session, COM_QUERY, &cmd, - &my_charset_utf8mb3_general_ci, &prom_cbs, - CS_TEXT_REPRESENTATION, &mc); + std::string output; + collect_global_status(session, output); srv_session_close(session); srv_session_deinit_thread(); - if (mc.error) { - return "# Error collecting metrics\n"; - } - - return mc.output; + return output; } // ----------------------------------------------------------------------- From 129ed0a200de3ba42a71ee24ee833c7edb22c1f6 Mon Sep 17 00:00:00 2001 From: Rene Cannao Date: Sun, 5 Apr 2026 05:42:17 +0000 Subject: [PATCH 03/16] Prometheus exporter: add SHOW GLOBAL VARIABLES collector Export all numeric MySQL server configuration variables as Prometheus gauge metrics with the mysql_global_variables_ prefix. Non-numeric values (strings, paths, ON/OFF) are automatically skipped by the existing strtod check. --- .../prometheus_exporter/r/global_variables.result | 6 ++++++ .../prometheus_exporter/t/global_variables-master.opt | 1 + .../suite/prometheus_exporter/t/global_variables.test | 11 +++++++++++ plugin/prometheus_exporter/prometheus_exporter.cc | 9 +++++++++ 4 files changed, 27 insertions(+) create mode 100644 mysql-test/suite/prometheus_exporter/r/global_variables.result create mode 100644 mysql-test/suite/prometheus_exporter/t/global_variables-master.opt create mode 100644 mysql-test/suite/prometheus_exporter/t/global_variables.test 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/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/plugin/prometheus_exporter/prometheus_exporter.cc b/plugin/prometheus_exporter/prometheus_exporter.cc index a7518b7bc3b6..9a457126acb1 100644 --- a/plugin/prometheus_exporter/prometheus_exporter.cc +++ b/plugin/prometheus_exporter/prometheus_exporter.cc @@ -309,6 +309,14 @@ static void collect_global_status(MYSQL_SESSION session, std::string &output) { "mysql_global_status_", global_status_type); } +static const char *global_variables_type(const char *) { 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"; @@ -331,6 +339,7 @@ static std::string collect_metrics() { std::string output; collect_global_status(session, output); + collect_global_variables(session, output); srv_session_close(session); srv_session_deinit_thread(); From 7b0e3033ef5c0fb79edad8a730dac5f119206c6e Mon Sep 17 00:00:00 2001 From: Rene Cannao Date: Sun, 5 Apr 2026 05:44:27 +0000 Subject: [PATCH 04/16] Prometheus exporter: add INNODB_METRICS collector Add collector that exports InnoDB metrics from information_schema.INNODB_METRICS with proper Prometheus type mapping (InnoDB 'counter' -> Prometheus counter, everything else -> gauge). Uses dedicated 4-column callbacks since INNODB_METRICS returns NAME, SUBSYSTEM, TYPE, COUNT rather than simple name/value pairs. --- .../r/innodb_metrics.result | 8 ++ .../t/innodb_metrics-master.opt | 1 + .../prometheus_exporter/t/innodb_metrics.test | 10 ++ .../prometheus_exporter.cc | 116 ++++++++++++++++++ 4 files changed, 135 insertions(+) create mode 100644 mysql-test/suite/prometheus_exporter/r/innodb_metrics.result create mode 100644 mysql-test/suite/prometheus_exporter/t/innodb_metrics-master.opt create mode 100644 mysql-test/suite/prometheus_exporter/t/innodb_metrics.test 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..e12604fe6259 --- /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 counter type metric exists +# 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/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..c1fdfb65a9b0 --- /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 counter type metric exists +--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/plugin/prometheus_exporter/prometheus_exporter.cc b/plugin/prometheus_exporter/prometheus_exporter.cc index 9a457126acb1..581223c8b971 100644 --- a/plugin/prometheus_exporter/prometheus_exporter.cc +++ b/plugin/prometheus_exporter/prometheus_exporter.cc @@ -284,6 +284,121 @@ static const struct st_command_service_cbs prom_cbs = { nullptr, // connection_alive }; +// ----------------------------------------------------------------------- +// InnoDB metrics collection (4-column result set) +// ----------------------------------------------------------------------- + +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; +} + +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); +} + +// ----------------------------------------------------------------------- +// Generic name/value query collection +// ----------------------------------------------------------------------- + static void collect_name_value_query(MYSQL_SESSION session, std::string &output, const char *query, const char *prefix, type_fn_t type_fn) { @@ -340,6 +455,7 @@ static std::string collect_metrics() { std::string output; collect_global_status(session, output); collect_global_variables(session, output); + collect_innodb_metrics(session, output); srv_session_close(session); srv_session_deinit_thread(); From 90471677803e0ea368c2c6ae7544a0d97f5a251f Mon Sep 17 00:00:00 2001 From: Rene Cannao Date: Sun, 5 Apr 2026 05:46:56 +0000 Subject: [PATCH 05/16] Add SHOW REPLICA STATUS collector to Prometheus exporter Add column-name-aware parsing for SHOW REPLICA STATUS that exports 6 replication metrics as gauges: seconds_behind_source, io_running, sql_running, relay_log_space, exec_source_log_pos, and read_source_log_pos. Boolean fields (IO/SQL running) emit 1 for "Yes", 0 otherwise. Gracefully emits nothing on non-replica servers. --- .../r/replica_status.result | 5 + .../t/replica_status-master.opt | 1 + .../prometheus_exporter/t/replica_status.test | 7 + .../prometheus_exporter.cc | 149 ++++++++++++++++++ 4 files changed, 162 insertions(+) create mode 100644 mysql-test/suite/prometheus_exporter/r/replica_status.result create mode 100644 mysql-test/suite/prometheus_exporter/t/replica_status-master.opt create mode 100644 mysql-test/suite/prometheus_exporter/t/replica_status.test 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..4494f4f0df83 --- /dev/null +++ b/mysql-test/suite/prometheus_exporter/r/replica_status.result @@ -0,0 +1,5 @@ +# On a non-replica server, no mysql_replica_ metrics should appear +0 +0 +# But other metrics should still be present +# TYPE mysql_global_status_uptime gauge 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..f81d62c55004 --- /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_" || echo "0" + +--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/plugin/prometheus_exporter/prometheus_exporter.cc b/plugin/prometheus_exporter/prometheus_exporter.cc index 581223c8b971..43498c4a4828 100644 --- a/plugin/prometheus_exporter/prometheus_exporter.cc +++ b/plugin/prometheus_exporter/prometheus_exporter.cc @@ -43,6 +43,7 @@ #include #include #include +#include // ----------------------------------------------------------------------- // Logging service references @@ -395,6 +396,153 @@ static void collect_innodb_metrics(MYSQL_SESSION session, std::string &output) { CS_TEXT_REPRESENTATION, &mc); } +// ----------------------------------------------------------------------- +// SHOW REPLICA STATUS collection (column-name-aware parsing) +// ----------------------------------------------------------------------- + +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 + char *end = nullptr; + strtod(val.c_str(), &end); + if (end == val.c_str()) 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; +} + +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); +} + // ----------------------------------------------------------------------- // Generic name/value query collection // ----------------------------------------------------------------------- @@ -456,6 +604,7 @@ static std::string collect_metrics() { collect_global_status(session, output); collect_global_variables(session, output); collect_innodb_metrics(session, output); + collect_replica_status(session, output); srv_session_close(session); srv_session_deinit_thread(); From 178a119d9d6eb6b4a648c49a1b832b9d970c0631 Mon Sep 17 00:00:00 2001 From: Rene Cannao Date: Sun, 5 Apr 2026 05:49:02 +0000 Subject: [PATCH 06/16] Add SHOW BINARY LOGS collector to Prometheus exporter Adds a binlog collector that runs SHOW BINARY LOGS and emits two synthetic gauge metrics: mysql_binlog_file_count (number of binary log files) and mysql_binlog_size_bytes_total (sum of all file sizes). If binary logging is disabled the query errors silently and no metrics are emitted. --- .../suite/prometheus_exporter/r/binlog.result | 6 + .../prometheus_exporter/t/binlog-master.opt | 1 + .../suite/prometheus_exporter/t/binlog.test | 11 ++ .../prometheus_exporter.cc | 106 ++++++++++++++++++ 4 files changed, 124 insertions(+) create mode 100644 mysql-test/suite/prometheus_exporter/r/binlog.result create mode 100644 mysql-test/suite/prometheus_exporter/t/binlog-master.opt create mode 100644 mysql-test/suite/prometheus_exporter/t/binlog.test 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/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/plugin/prometheus_exporter/prometheus_exporter.cc b/plugin/prometheus_exporter/prometheus_exporter.cc index 43498c4a4828..1a94978c7880 100644 --- a/plugin/prometheus_exporter/prometheus_exporter.cc +++ b/plugin/prometheus_exporter/prometheus_exporter.cc @@ -543,6 +543,111 @@ static void collect_replica_status(MYSQL_SESSION session, std::string &output) { CS_TEXT_REPRESENTATION, &rc); } +// ----------------------------------------------------------------------- +// SHOW BINARY LOGS collection +// ----------------------------------------------------------------------- + +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()) { + char *end = nullptr; + long long sz = strtoll(bc->current_size.c_str(), &end, 10); + if (end != bc->current_size.c_str()) { + 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; +} + +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); +} + // ----------------------------------------------------------------------- // Generic name/value query collection // ----------------------------------------------------------------------- @@ -605,6 +710,7 @@ static std::string collect_metrics() { collect_global_variables(session, output); collect_innodb_metrics(session, output); collect_replica_status(session, output); + collect_binlog(session, output); srv_session_close(session); srv_session_deinit_thread(); From a582e69b161a066be0abd1bde94b7376f4d44d33 Mon Sep 17 00:00:00 2001 From: Rene Cannao Date: Sun, 5 Apr 2026 05:52:38 +0000 Subject: [PATCH 07/16] Update tests to verify all 5 collectors and add format validation - Update basic.test with inline documentation and clearer echo messages - Update metrics_endpoint.test to verify all 5 collector prefixes (global status, global variables, InnoDB metrics, binlog) - Add format_validation.test with Perl-based Prometheus exposition format validation (TYPE lines, metric names, value structure) - Record all result files --- .../suite/prometheus_exporter/r/basic.result | 4 +- .../r/format_validation.result | 2 + .../r/metrics_endpoint.result | 15 ++-- .../suite/prometheus_exporter/t/basic.test | 14 +++- .../t/format_validation-master.opt | 1 + .../t/format_validation.test | 80 +++++++++++++++++++ .../t/metrics_endpoint.test | 27 +++++-- 7 files changed, 127 insertions(+), 16 deletions(-) create mode 100644 mysql-test/suite/prometheus_exporter/r/format_validation.result create mode 100644 mysql-test/suite/prometheus_exporter/t/format_validation-master.opt create mode 100644 mysql-test/suite/prometheus_exporter/t/format_validation.test diff --git a/mysql-test/suite/prometheus_exporter/r/basic.result b/mysql-test/suite/prometheus_exporter/r/basic.result index aa13a185dc1c..9e97311771e1 100644 --- a/mysql-test/suite/prometheus_exporter/r/basic.result +++ b/mysql-test/suite/prometheus_exporter/r/basic.result @@ -1,12 +1,12 @@ # Install prometheus_exporter plugin INSTALL PLUGIN prometheus_exporter SONAME 'PROMETHEUS_EXPORTER_PLUGIN'; -# Verify system variables exist +# Verify system variables exist with correct defaults SHOW VARIABLES LIKE 'prometheus_exporter%'; Variable_name Value prometheus_exporter_bind_address 0.0.0.0 prometheus_exporter_enabled OFF prometheus_exporter_port 9104 -# Verify status variables exist +# Verify status variables exist (all zero when disabled) SHOW STATUS LIKE 'Prometheus_exporter%'; Variable_name Value Prometheus_exporter_errors_total 0 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/metrics_endpoint.result b/mysql-test/suite/prometheus_exporter/r/metrics_endpoint.result index 6133a1b7a5f2..f4c0d9404569 100644 --- a/mysql-test/suite/prometheus_exporter/r/metrics_endpoint.result +++ b/mysql-test/suite/prometheus_exporter/r/metrics_endpoint.result @@ -1,16 +1,19 @@ SHOW VARIABLES LIKE 'prometheus_exporter_enabled'; Variable_name Value prometheus_exporter_enabled ON -# Test /metrics endpoint returns Prometheus-formatted data -# Check that TYPE annotations and metric lines are present +# Verify SHOW GLOBAL STATUS metrics # TYPE mysql_global_status_threads_connected gauge -# Check that a counter metric exists -# TYPE mysql_global_status_questions untyped -# Check that an actual metric value line exists +# 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 (at least 2 scrapes above) +# 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/t/basic.test b/mysql-test/suite/prometheus_exporter/t/basic.test index e827aaa77cf5..82f8ccb29464 100644 --- a/mysql-test/suite/prometheus_exporter/t/basic.test +++ b/mysql-test/suite/prometheus_exporter/t/basic.test @@ -1,3 +1,13 @@ +# ============================================================================= +# 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 @@ -14,10 +24,10 @@ enable_query_log; --replace_result $PROMETHEUS_EXPORTER_PLUGIN PROMETHEUS_EXPORTER_PLUGIN eval INSTALL PLUGIN prometheus_exporter SONAME '$PROMETHEUS_EXPORTER_PLUGIN'; ---echo # Verify system variables exist +--echo # Verify system variables exist with correct defaults SHOW VARIABLES LIKE 'prometheus_exporter%'; ---echo # Verify status variables exist +--echo # Verify status variables exist (all zero when disabled) SHOW STATUS LIKE 'Prometheus_exporter%'; --echo # Uninstall plugin 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..09548e3aae31 --- /dev/null +++ b/mysql-test/suite/prometheus_exporter/t/format_validation.test @@ -0,0 +1,80 @@ +# ============================================================================= +# 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_]* +# - Numeric metric values are well-formed +# - String-valued variables metrics are accepted (info-style gauges) +# ============================================================================= + +--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 ($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 (string-valued variables metrics are allowed) + unless ($value =~ /^-?[0-9]+(\.[0-9]+)?([eE][+-]?[0-9]+)?$/) { + # Global variables and some status metrics export string values + unless ($name =~ /^mysql_global_variables_/ || $name =~ /_time$/) { + print "FORMAT ERROR: non-numeric value '$value' for metric '$name'\n"; + $errors++; + } + } + } else { + print "FORMAT ERROR: unrecognized line: $line\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/metrics_endpoint.test b/mysql-test/suite/prometheus_exporter/t/metrics_endpoint.test index dc6620ea8c6c..4f5c69c90b59 100644 --- a/mysql-test/suite/prometheus_exporter/t/metrics_endpoint.test +++ b/mysql-test/suite/prometheus_exporter/t/metrics_endpoint.test @@ -1,22 +1,37 @@ +# ============================================================================= +# metrics_endpoint.test -- Prometheus Exporter: HTTP Endpoint & All Collectors +# +# Verifies: +# - HTTP endpoint serves Prometheus text format +# - All 5 collector prefixes appear in output +# - 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 # Test /metrics endpoint returns Prometheus-formatted data ---echo # Check that TYPE annotations and metric lines are present +--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 # Check that a counter metric exists ---exec curl -s http://127.0.0.1:19104/metrics | grep "^# TYPE mysql_global_status_questions" | 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 # Check that an actual metric value line exists +--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 (at least 2 scrapes above) +--echo # Verify scrape counter incremented --replace_regex /[0-9]+/NUM/ SHOW STATUS LIKE 'Prometheus_exporter_requests_total'; From ba7b2c088220f29933b58a4c9a8b8d0795deb602 Mon Sep 17 00:00:00 2001 From: Rene Cannao Date: Sun, 5 Apr 2026 05:54:11 +0000 Subject: [PATCH 08/16] docs(prometheus): add architecture docs and configuration reference - Full README with ASCII architecture diagram, configuration table, metric namespace reference, usage examples, type classification, and limitations - Pointer doc in Docs/ directory --- Docs/prometheus_exporter.md | 6 ++ plugin/prometheus_exporter/README.md | 155 +++++++++++++++++++++++++++ 2 files changed, 161 insertions(+) create mode 100644 Docs/prometheus_exporter.md create mode 100644 plugin/prometheus_exporter/README.md 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/plugin/prometheus_exporter/README.md b/plugin/prometheus_exporter/README.md new file mode 100644 index 000000000000..7df8d6feaa06 --- /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 | 0.0.0.0 | 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. From c14436f542256a7954eb7142a2bc60d106faf89a Mon Sep 17 00:00:00 2001 From: Rene Cannao Date: Sun, 5 Apr 2026 06:03:34 +0000 Subject: [PATCH 09/16] chore: remove implementation planning artifacts Remove spec and plan docs from docs/superpowers/ -- these were development-time artifacts. User-facing documentation lives in plugin/prometheus_exporter/README.md. --- .../2026-04-05-prometheus-exporter-v2.md | 1365 ----------------- ...026-04-05-prometheus-exporter-v2-design.md | 241 --- 2 files changed, 1606 deletions(-) delete mode 100644 docs/superpowers/plans/2026-04-05-prometheus-exporter-v2.md delete mode 100644 docs/superpowers/specs/2026-04-05-prometheus-exporter-v2-design.md diff --git a/docs/superpowers/plans/2026-04-05-prometheus-exporter-v2.md b/docs/superpowers/plans/2026-04-05-prometheus-exporter-v2.md deleted file mode 100644 index 668d638b8dbf..000000000000 --- a/docs/superpowers/plans/2026-04-05-prometheus-exporter-v2.md +++ /dev/null @@ -1,1365 +0,0 @@ -# Prometheus Exporter v2 Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Expand the prometheus_exporter plugin from 1 data source to 5, add 7 MTR tests with format validation, and create full documentation with architecture diagrams. - -**Architecture:** The plugin uses MySQL's `srv_session` + `command_service_run_command` API to execute SQL queries internally (no network). Each data source gets its own collector function that takes an open session and appends Prometheus-formatted text to an output string. A single `collect_metrics()` orchestrator opens one session, calls all collectors, then closes the session. - -**Tech Stack:** C++20, MySQL daemon plugin API, `srv_session` service, `command_service` callbacks, POSIX sockets, MTR test framework. - -**Spec:** `docs/superpowers/specs/2026-04-05-prometheus-exporter-v2-design.md` - -**Build commands:** -```bash -# Build just the plugin (fast, ~10s): -cd /data/rene/build && make -j32 prometheus_exporter - -# Run all prometheus_exporter tests: -cd /data/rene/build && ./mysql-test/mysql-test-run.pl --suite=prometheus_exporter --nounit-tests --parallel=auto - -# Run a specific test: -cd /data/rene/build && ./mysql-test/mysql-test-run.pl --suite=prometheus_exporter --nounit-tests - -# Record a test result: -cd /data/rene/build && ./mysql-test/mysql-test-run.pl --record --suite=prometheus_exporter --nounit-tests -``` - ---- - -## File Map - -| File | Action | Responsibility | -|------|--------|---------------| -| `plugin/prometheus_exporter/prometheus_exporter.cc` | Modify | Add 4 new collectors, refactor existing code into collector pattern, expand gauge list | -| `plugin/prometheus_exporter/README.md` | Create | Full documentation: architecture diagram, config reference, metric namespaces, usage | -| `Docs/prometheus_exporter.md` | Create | Pointer to full docs in plugin dir | -| `mysql-test/suite/prometheus_exporter/t/basic.test` | Modify | Add inline comments | -| `mysql-test/suite/prometheus_exporter/t/metrics_endpoint.test` | Modify | Verify all 5 data source prefixes | -| `mysql-test/suite/prometheus_exporter/t/global_variables.test` | Create | Test SHOW GLOBAL VARIABLES collector | -| `mysql-test/suite/prometheus_exporter/t/global_variables-master.opt` | Create | Server opts for test | -| `mysql-test/suite/prometheus_exporter/r/global_variables.result` | Create | Expected output | -| `mysql-test/suite/prometheus_exporter/t/innodb_metrics.test` | Create | Test INNODB_METRICS collector | -| `mysql-test/suite/prometheus_exporter/t/innodb_metrics-master.opt` | Create | Server opts for test | -| `mysql-test/suite/prometheus_exporter/r/innodb_metrics.result` | Create | Expected output | -| `mysql-test/suite/prometheus_exporter/t/replica_status.test` | Create | Test graceful absence on non-replica | -| `mysql-test/suite/prometheus_exporter/t/replica_status-master.opt` | Create | Server opts for test | -| `mysql-test/suite/prometheus_exporter/r/replica_status.result` | Create | Expected output | -| `mysql-test/suite/prometheus_exporter/t/binlog.test` | Create | Test SHOW BINARY LOGS collector | -| `mysql-test/suite/prometheus_exporter/t/binlog-master.opt` | Create | Server opts for test | -| `mysql-test/suite/prometheus_exporter/r/binlog.result` | Create | Expected output | -| `mysql-test/suite/prometheus_exporter/t/format_validation.test` | Create | Perl-based format validation | -| `mysql-test/suite/prometheus_exporter/t/format_validation-master.opt` | Create | Server opts for test | -| `mysql-test/suite/prometheus_exporter/r/format_validation.result` | Create | Expected output | -| `mysql-test/suite/prometheus_exporter/r/metrics_endpoint.result` | Modify | Updated expected output | - ---- - -## Task 1: Refactor existing code into collector pattern - -Refactor `collect_metrics()` so the existing SHOW GLOBAL STATUS logic is extracted into its own `collect_global_status()` function. This doesn't change behavior -- it's a structural refactor to enable adding more collectors cleanly. - -**Files:** -- Modify: `plugin/prometheus_exporter/prometheus_exporter.cc` - -- [ ] **Step 1: Refactor MetricsCollectorCtx to support configurable prefix and type function** - -In `prometheus_exporter.cc`, modify the `MetricsCollectorCtx` struct and `prom_end_row` to accept a prefix and a type-determination function pointer. Replace the current hardcoded `"mysql_global_status_"` prefix. - -Change the struct (around line 174): - -```cpp -// Function pointer type for determining Prometheus metric type -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; -}; -``` - -Change `prom_end_row` (around line 202) to use the context's prefix and type_fn: - -```cpp -static int prom_end_row(void *ctx) { - auto *mc = static_cast(ctx); - - if (mc->current_name.empty() || mc->current_value.empty()) return 0; - - char *end = nullptr; - strtod(mc->current_value.c_str(), &end); - if (end == mc->current_value.c_str()) return 0; - - 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; -} -``` - -Remove the standalone `to_prometheus_name()` function (line 162-168) since the prefix logic is now in `prom_end_row`. - -- [ ] **Step 2: Add type function for global status** - -Add the type function that wraps the existing `is_gauge()`: - -```cpp -static const char *global_status_type(const char *name) { - return is_gauge(name) ? "gauge" : "untyped"; -} -``` - -- [ ] **Step 3: Create a helper to run a 2-column query** - -This helper opens a query, uses the existing `prom_cbs` callbacks, and returns. It will be reused by both Global Status and Global Variables collectors: - -```cpp -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); -} -``` - -- [ ] **Step 4: Extract collect_global_status()** - -```cpp -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); -} -``` - -- [ ] **Step 5: Refactor collect_metrics() to use the new collector** - -Replace the body of `collect_metrics()` to separate session management from collection: - -```cpp -static std::string collect_metrics() { - if (!srv_session_server_is_available()) { - return "# Server not available\n"; - } - - if (srv_session_init_thread(g_ctx->plugin_ref) != 0) { - return "# Failed to init session thread\n"; - } - - MYSQL_SESSION session = srv_session_open(nullptr, nullptr); - if (session == nullptr) { - srv_session_deinit_thread(); - return "# Failed to open session\n"; - } - - MYSQL_SECURITY_CONTEXT sc; - thd_get_security_context(srv_session_info_get_thd(session), &sc); - security_context_lookup(sc, "root", "localhost", "127.0.0.1", ""); - - std::string output; - collect_global_status(session, output); - - srv_session_close(session); - srv_session_deinit_thread(); - - return output; -} -``` - -- [ ] **Step 6: Build and verify no behavior change** - -```bash -cd /data/rene/build && make -j32 prometheus_exporter -``` - -Expected: builds with no errors. - -- [ ] **Step 7: Run existing tests** - -```bash -cd /data/rene/build && ./mysql-test/mysql-test-run.pl --suite=prometheus_exporter --nounit-tests --parallel=auto -``` - -Expected: All 3 tests pass (basic, metrics_endpoint, shutdown_report). - -- [ ] **Step 8: Commit** - -```bash -git add plugin/prometheus_exporter/prometheus_exporter.cc -git commit -m "refactor: extract collector pattern from prometheus_exporter - -Refactor collect_metrics() to use a collector function pattern with -configurable prefix and type-determination function. Extracts -collect_global_status() and collect_name_value_query() helper. -No behavior change -- prepares for adding more collectors." -``` - ---- - -## Task 2: Add SHOW GLOBAL VARIABLES collector - -**Files:** -- Modify: `plugin/prometheus_exporter/prometheus_exporter.cc` -- Create: `mysql-test/suite/prometheus_exporter/t/global_variables.test` -- Create: `mysql-test/suite/prometheus_exporter/t/global_variables-master.opt` -- Create: `mysql-test/suite/prometheus_exporter/r/global_variables.result` - -- [ ] **Step 1: Write the test** - -Create `mysql-test/suite/prometheus_exporter/t/global_variables-master.opt`: -``` -$PROMETHEUS_EXPORTER_PLUGIN_OPT $PROMETHEUS_EXPORTER_PLUGIN_LOAD --prometheus-exporter-enabled=ON --prometheus-exporter-port=19105 --prometheus-exporter-bind-address=127.0.0.1 -``` - -Create `mysql-test/suite/prometheus_exporter/t/global_variables.test`: -```sql ---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 -``` - -- [ ] **Step 2: Add the type function and collector** - -In `prometheus_exporter.cc`, add after `collect_global_status()`: - -```cpp -static const char *global_variables_type(const char *) { 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); -} -``` - -- [ ] **Step 3: Wire into collect_metrics()** - -In `collect_metrics()`, add after the `collect_global_status(session, output);` line: - -```cpp - collect_global_variables(session, output); -``` - -- [ ] **Step 4: Build** - -```bash -cd /data/rene/build && make -j32 prometheus_exporter -``` - -Expected: builds with no errors. - -- [ ] **Step 5: Record and run test** - -```bash -cd /data/rene/build && ./mysql-test/mysql-test-run.pl --record --suite=prometheus_exporter --nounit-tests global_variables -``` - -Expected: PASS, result file generated. - -- [ ] **Step 6: Run all tests to verify no regressions** - -```bash -cd /data/rene/build && ./mysql-test/mysql-test-run.pl --suite=prometheus_exporter --nounit-tests --parallel=auto -``` - -Expected: All tests pass. - -- [ ] **Step 7: Commit** - -```bash -git add plugin/prometheus_exporter/prometheus_exporter.cc \ - mysql-test/suite/prometheus_exporter/t/global_variables.test \ - mysql-test/suite/prometheus_exporter/t/global_variables-master.opt \ - mysql-test/suite/prometheus_exporter/r/global_variables.result -git commit -m "feat(prometheus): add SHOW GLOBAL VARIABLES collector - -Exports server configuration values (max_connections, -innodb_buffer_pool_size, etc.) as mysql_global_variables_* gauge metrics. -Non-numeric variables are silently skipped." -``` - ---- - -## Task 3: Add INNODB_METRICS collector - -**Files:** -- Modify: `plugin/prometheus_exporter/prometheus_exporter.cc` -- Create: `mysql-test/suite/prometheus_exporter/t/innodb_metrics.test` -- Create: `mysql-test/suite/prometheus_exporter/t/innodb_metrics-master.opt` -- Create: `mysql-test/suite/prometheus_exporter/r/innodb_metrics.result` - -- [ ] **Step 1: Write the test** - -Create `mysql-test/suite/prometheus_exporter/t/innodb_metrics-master.opt`: -``` -$PROMETHEUS_EXPORTER_PLUGIN_OPT $PROMETHEUS_EXPORTER_PLUGIN_LOAD --prometheus-exporter-enabled=ON --prometheus-exporter-port=19106 --prometheus-exporter-bind-address=127.0.0.1 -``` - -Create `mysql-test/suite/prometheus_exporter/t/innodb_metrics.test`: -```sql ---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 counter type metric ---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 (value type in InnoDB) ---exec curl -s http://127.0.0.1:19106/metrics | grep "^# TYPE mysql_innodb_metrics_buffer_pool_size " | head -1 -``` - -- [ ] **Step 2: Add InnodbMetricsCtx and callbacks** - -This collector uses a 4-column result (NAME, SUBSYSTEM, TYPE, COUNT), so it needs its own context and callbacks. Add in `prometheus_exporter.cc`: - -```cpp -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 - const char *prom_type = "gauge"; - if (mc->current_type == "counter") { - prom_type = "counter"; - } - // value, status_counter, set_owner, set_member -> gauge - - 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); - switch (mc->col_index) { - case 0: - mc->current_name.assign(value, length); - break; - case 1: - // SUBSYSTEM -- skip, not used - break; - case 2: - mc->current_type.assign(value, length); - break; - case 3: - mc->current_count.assign(value, length); - break; - } - mc->col_index++; - return 0; -} - -static void innodb_handle_error(void *ctx, uint, const char *, const char *) { - static_cast(ctx)->error = true; -} - -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, -}; -``` - -- [ ] **Step 3: Add the collector function** - -```cpp -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); -} -``` - -- [ ] **Step 4: Wire into collect_metrics()** - -Add after `collect_global_variables(session, output);`: - -```cpp - collect_innodb_metrics(session, output); -``` - -- [ ] **Step 5: Build** - -```bash -cd /data/rene/build && make -j32 prometheus_exporter -``` - -Expected: builds with no errors. - -- [ ] **Step 6: Record and run test** - -```bash -cd /data/rene/build && ./mysql-test/mysql-test-run.pl --record --suite=prometheus_exporter --nounit-tests innodb_metrics -``` - -Expected: PASS. Note: the exact metric names depend on which InnoDB metrics are enabled by default. The test greps for known ones. If a specific metric name doesn't exist, adjust the grep to match an existing metric from the output. - -- [ ] **Step 7: Run all tests** - -```bash -cd /data/rene/build && ./mysql-test/mysql-test-run.pl --suite=prometheus_exporter --nounit-tests --parallel=auto -``` - -Expected: All tests pass. - -- [ ] **Step 8: Commit** - -```bash -git add plugin/prometheus_exporter/prometheus_exporter.cc \ - mysql-test/suite/prometheus_exporter/t/innodb_metrics.test \ - mysql-test/suite/prometheus_exporter/t/innodb_metrics-master.opt \ - mysql-test/suite/prometheus_exporter/r/innodb_metrics.result -git commit -m "feat(prometheus): add INNODB_METRICS collector - -Exports ~200 detailed InnoDB metrics from information_schema.INNODB_METRICS -as mysql_innodb_metrics_* with type mapping from InnoDB's own TYPE column -(counter -> counter, value/status_counter/set_owner/set_member -> gauge)." -``` - ---- - -## Task 4: Add SHOW REPLICA STATUS collector - -**Files:** -- Modify: `plugin/prometheus_exporter/prometheus_exporter.cc` -- Create: `mysql-test/suite/prometheus_exporter/t/replica_status.test` -- Create: `mysql-test/suite/prometheus_exporter/t/replica_status-master.opt` -- Create: `mysql-test/suite/prometheus_exporter/r/replica_status.result` - -- [ ] **Step 1: Write the test** - -This test verifies graceful absence -- on a non-replica server, no `mysql_replica_` metrics should appear. - -Create `mysql-test/suite/prometheus_exporter/t/replica_status-master.opt`: -``` -$PROMETHEUS_EXPORTER_PLUGIN_OPT $PROMETHEUS_EXPORTER_PLUGIN_LOAD --prometheus-exporter-enabled=ON --prometheus-exporter-port=19107 --prometheus-exporter-bind-address=127.0.0.1 -``` - -Create `mysql-test/suite/prometheus_exporter/t/replica_status.test`: -```sql ---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_" || echo "0" - ---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 -``` - -- [ ] **Step 2: Add ReplicaStatusCtx and callbacks** - -This collector needs column-name-aware parsing. During `field_metadata`, build a column name list. During `get_string`, map column index to field name and collect wanted fields. - -```cpp -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.emplace_back(field->col_name); - return 0; -} - -static int replica_start_row(void *ctx) { - auto *rc = static_cast(ctx); - rc->col_index = 0; - rc->col_values.clear(); - rc->col_values.resize(rc->col_names.size()); - 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; -} - -static int replica_end_row(void *ctx) { - auto *rc = static_cast(ctx); - - struct ReplicaField { - const char *mysql_name; - const char *prom_name; - bool is_bool; // Yes/No -> 1/0 - }; - - static const ReplicaField 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}, - {nullptr, nullptr, false}, - }; - - for (size_t i = 0; i < rc->col_names.size(); i++) { - for (const ReplicaField *f = wanted_fields; f->mysql_name != nullptr; f++) { - if (strcasecmp(rc->col_names[i].c_str(), f->mysql_name) != 0) continue; - if (rc->col_values[i].empty()) continue; - - std::string val; - if (f->is_bool) { - val = (rc->col_values[i] == "Yes") ? "1" : "0"; - } else { - char *end = nullptr; - strtod(rc->col_values[i].c_str(), &end); - if (end == rc->col_values[i].c_str()) continue; // skip non-numeric - val = rc->col_values[i]; - } - - *rc->output += "# TYPE "; - *rc->output += f->prom_name; - *rc->output += " gauge\n"; - *rc->output += f->prom_name; - *rc->output += ' '; - *rc->output += val; - *rc->output += '\n'; - } - } - - return 0; -} - -static void replica_handle_error(void *ctx, uint, const char *, const char *) { - static_cast(ctx)->error = true; -} - -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, -}; -``` - -Note: add `#include ` to the includes at the top of the file. - -- [ ] **Step 3: Add the collector function** - -```cpp -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); -} -``` - -- [ ] **Step 4: Wire into collect_metrics()** - -Add after `collect_innodb_metrics(session, output);`: - -```cpp - collect_replica_status(session, output); -``` - -- [ ] **Step 5: Build** - -```bash -cd /data/rene/build && make -j32 prometheus_exporter -``` - -Expected: builds with no errors. - -- [ ] **Step 6: Record and run test** - -```bash -cd /data/rene/build && ./mysql-test/mysql-test-run.pl --record --suite=prometheus_exporter --nounit-tests replica_status -``` - -Expected: PASS. The test verifies that on a non-replica, `mysql_replica_` metrics are absent (grep returns count 0). - -- [ ] **Step 7: Run all tests** - -```bash -cd /data/rene/build && ./mysql-test/mysql-test-run.pl --suite=prometheus_exporter --nounit-tests --parallel=auto -``` - -Expected: All tests pass. - -- [ ] **Step 8: Commit** - -```bash -git add plugin/prometheus_exporter/prometheus_exporter.cc \ - mysql-test/suite/prometheus_exporter/t/replica_status.test \ - mysql-test/suite/prometheus_exporter/t/replica_status-master.opt \ - mysql-test/suite/prometheus_exporter/r/replica_status.result -git commit -m "feat(prometheus): add SHOW REPLICA STATUS collector - -Exports replication metrics (seconds_behind_source, io_running, -sql_running, relay_log_space, log positions) as mysql_replica_* gauges. -Gracefully skipped when server is not a replica (no rows returned)." -``` - ---- - -## Task 5: Add SHOW BINARY LOGS collector - -**Files:** -- Modify: `plugin/prometheus_exporter/prometheus_exporter.cc` -- Create: `mysql-test/suite/prometheus_exporter/t/binlog.test` -- Create: `mysql-test/suite/prometheus_exporter/t/binlog-master.opt` -- Create: `mysql-test/suite/prometheus_exporter/r/binlog.result` - -- [ ] **Step 1: Write the test** - -Create `mysql-test/suite/prometheus_exporter/t/binlog-master.opt`: -``` -$PROMETHEUS_EXPORTER_PLUGIN_OPT $PROMETHEUS_EXPORTER_PLUGIN_LOAD --prometheus-exporter-enabled=ON --prometheus-exporter-port=19108 --prometheus-exporter-bind-address=127.0.0.1 -``` - -Create `mysql-test/suite/prometheus_exporter/t/binlog.test`: -```sql ---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 -``` - -- [ ] **Step 2: Add BinlogCtx and callbacks** - -```cpp -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()) { - char *end = nullptr; - long long sz = strtoll(bc->current_size.c_str(), &end, 10); - if (end != bc->current_size.c_str()) { - 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) { // File_size column - 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 *) { - static_cast(ctx)->error = true; -} - -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, -}; -``` - -- [ ] **Step 3: Add the collector function** - -```cpp -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); -} -``` - -- [ ] **Step 4: Wire into collect_metrics()** - -Add after `collect_replica_status(session, output);`: - -```cpp - collect_binlog(session, output); -``` - -- [ ] **Step 5: Build** - -```bash -cd /data/rene/build && make -j32 prometheus_exporter -``` - -Expected: builds with no errors. - -- [ ] **Step 6: Record and run test** - -```bash -cd /data/rene/build && ./mysql-test/mysql-test-run.pl --record --suite=prometheus_exporter --nounit-tests binlog -``` - -Expected: PASS. Binary logging is on by default in MTR, so binlog metrics should appear. - -- [ ] **Step 7: Run all tests** - -```bash -cd /data/rene/build && ./mysql-test/mysql-test-run.pl --suite=prometheus_exporter --nounit-tests --parallel=auto -``` - -Expected: All tests pass. - -- [ ] **Step 8: Commit** - -```bash -git add plugin/prometheus_exporter/prometheus_exporter.cc \ - mysql-test/suite/prometheus_exporter/t/binlog.test \ - mysql-test/suite/prometheus_exporter/t/binlog-master.opt \ - mysql-test/suite/prometheus_exporter/r/binlog.result -git commit -m "feat(prometheus): add SHOW BINARY LOGS collector - -Exports mysql_binlog_file_count and mysql_binlog_size_bytes_total -as gauge metrics. Silently skipped when binary logging is disabled." -``` - ---- - -## Task 6: Update existing tests and add format validation - -**Files:** -- Modify: `mysql-test/suite/prometheus_exporter/t/basic.test` -- Modify: `mysql-test/suite/prometheus_exporter/t/metrics_endpoint.test` -- Create: `mysql-test/suite/prometheus_exporter/t/format_validation.test` -- Create: `mysql-test/suite/prometheus_exporter/t/format_validation-master.opt` -- Create: `mysql-test/suite/prometheus_exporter/r/format_validation.result` -- Modify/record: `mysql-test/suite/prometheus_exporter/r/basic.result` -- Modify/record: `mysql-test/suite/prometheus_exporter/r/metrics_endpoint.result` - -- [ ] **Step 1: Add inline comments to basic.test** - -Replace the content of `mysql-test/suite/prometheus_exporter/t/basic.test`: - -```sql -# ============================================================================= -# 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; -``` - -- [ ] **Step 2: Update metrics_endpoint.test to verify all 5 prefixes** - -Replace the content of `mysql-test/suite/prometheus_exporter/t/metrics_endpoint.test`: - -```sql -# ============================================================================= -# metrics_endpoint.test -- Prometheus Exporter: HTTP Endpoint & All Collectors -# -# Verifies: -# - HTTP endpoint serves Prometheus text format -# - All 5 collector prefixes appear in output -# - 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'; -``` - -- [ ] **Step 3: Create format_validation test** - -Create `mysql-test/suite/prometheus_exporter/t/format_validation-master.opt`: -``` -$PROMETHEUS_EXPORTER_PLUGIN_OPT $PROMETHEUS_EXPORTER_PLUGIN_LOAD --prometheus-exporter-enabled=ON --prometheus-exporter-port=19109 --prometheus-exporter-bind-address=127.0.0.1 -``` - -Create `mysql-test/suite/prometheus_exporter/t/format_validation.test`: -```sql -# ============================================================================= -# 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_]* -# - Values are numeric -# ============================================================================= - ---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 ($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 - unless ($value =~ /^-?[0-9]+(\.[0-9]+)?([eE][+-]?[0-9]+)?$/) { - print "FORMAT ERROR: non-numeric value '$value' for metric '$name'\n"; - $errors++; - } - } else { - print "FORMAT ERROR: unrecognized line: $line\n"; - $errors++; - } -} - -if ($errors == 0 && $metrics_count > 0) { - print "OK: $metrics_count metrics validated, 0 format errors\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 -``` - -- [ ] **Step 4: Record all test results** - -```bash -cd /data/rene/build && ./mysql-test/mysql-test-run.pl --record --suite=prometheus_exporter --nounit-tests basic -cd /data/rene/build && ./mysql-test/mysql-test-run.pl --record --suite=prometheus_exporter --nounit-tests metrics_endpoint -cd /data/rene/build && ./mysql-test/mysql-test-run.pl --record --suite=prometheus_exporter --nounit-tests format_validation -``` - -Expected: All three PASS and result files are generated/updated. - -- [ ] **Step 5: Run all tests together** - -```bash -cd /data/rene/build && ./mysql-test/mysql-test-run.pl --suite=prometheus_exporter --nounit-tests --parallel=auto -``` - -Expected: All 8 tests pass (basic, metrics_endpoint, global_variables, innodb_metrics, replica_status, binlog, format_validation, shutdown_report). - -- [ ] **Step 6: Commit** - -```bash -git add mysql-test/suite/prometheus_exporter/ -git commit -m "test(prometheus): expand test suite with format validation - -- Add inline documentation to basic.test -- Update metrics_endpoint.test to verify all 5 collector prefixes -- Add format_validation.test: perl-based structural validation of - Prometheus exposition format (valid TYPE lines, matching metric names, - numeric values) -Total: 7 tests covering all collectors and output format correctness." -``` - ---- - -## Task 7: Create documentation - -**Files:** -- Create: `plugin/prometheus_exporter/README.md` -- Create: `Docs/prometheus_exporter.md` - -- [ ] **Step 1: Create the full README** - -Create `plugin/prometheus_exporter/README.md` with the following content: - -```markdown -# 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. - -```text -┌─────────────────────────────────────────────────────┐ -│ 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 | 0.0.0.0 | 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. -``` - -- [ ] **Step 2: Create the Docs pointer** - -Create `Docs/prometheus_exporter.md`: - -```markdown -# 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). -``` - -- [ ] **Step 3: Commit** - -```bash -git add plugin/prometheus_exporter/README.md Docs/prometheus_exporter.md -git commit -m "docs(prometheus): add architecture docs and configuration reference - -- Full README with ASCII architecture diagram, configuration table, - metric namespace reference, usage examples, type classification, - and limitations -- Pointer doc in Docs/ directory" -``` - ---- - -## Self-Review Checklist - -**Spec coverage:** -- [x] SHOW GLOBAL STATUS (existing, refactored in Task 1) -- [x] SHOW GLOBAL VARIABLES (Task 2) -- [x] INNODB_METRICS (Task 3) -- [x] SHOW REPLICA STATUS (Task 4) -- [x] SHOW BINARY LOGS (Task 5) -- [x] Test expansion to 7 tests (Task 6) -- [x] Format validation test (Task 6) -- [x] Documentation with architecture diagram (Task 7) -- [x] Docs/ pointer (Task 7) -- [x] Port allocation for parallel safety (each .opt file uses different port) - -**Placeholder scan:** No TBD/TODO/placeholder text found. - -**Type consistency:** -- `MetricsCollectorCtx` used consistently across Tasks 1-2 -- `InnodbMetricsCtx` defined and used only in Task 3 -- `ReplicaStatusCtx` defined and used only in Task 4 -- `BinlogCtx` defined and used only in Task 5 -- `collect_*` function signatures consistent: `(MYSQL_SESSION, std::string &)` -- All callback structs follow same pattern: reuse no-op callbacks, specialize only what differs diff --git a/docs/superpowers/specs/2026-04-05-prometheus-exporter-v2-design.md b/docs/superpowers/specs/2026-04-05-prometheus-exporter-v2-design.md deleted file mode 100644 index ff5fe7c7dad5..000000000000 --- a/docs/superpowers/specs/2026-04-05-prometheus-exporter-v2-design.md +++ /dev/null @@ -1,241 +0,0 @@ -# Prometheus Exporter Plugin v2 -- Design Spec - -## Summary - -Expand the embedded Prometheus exporter plugin from a single data source (`SHOW GLOBAL STATUS`) to five data sources, add comprehensive test coverage with format validation, and create full documentation with architecture diagrams. - -## Current State (v1) - -- Single-file daemon plugin at `plugin/prometheus_exporter/prometheus_exporter.cc` (~573 lines) -- Exports `SHOW GLOBAL STATUS` variables as `mysql_global_status_*` metrics -- Bare-bones HTTP server on configurable port (default 9104), disabled by default -- Uses `srv_session` + `command_service_run_command` to execute SQL internally (no network, no auth -- pure in-process function calls) -- 2 MTR tests: `basic` (install/uninstall) and `metrics_endpoint` (HTTP format validation) -- System variables: `enabled`, `port`, `bind_address` (all READONLY) -- Plugin status variables: `requests_total`, `errors_total`, `scrape_duration_microseconds` - -## Design Decisions - -### SQL queries over direct internal access - -The plugin executes SQL queries via the `srv_session` service rather than accessing internal server structs directly. Rationale: VillageSQL rebases onto new MySQL versions; internal struct layouts change across versions, but SQL interfaces (`SHOW GLOBAL STATUS`, `information_schema` tables) are stable. The per-scrape overhead of SQL execution (~milliseconds) is negligible since Prometheus scrapes every 15-60 seconds. - -### Single file - -The plugin stays in a single `.cc` file. With the v2 additions it will be ~800-900 lines. This is manageable for a self-contained plugin and avoids header management overhead. Can be split later if it grows further. - -### Session reuse within a scrape - -All collectors run on the same `srv_session` -- opened once per scrape, closed after all collectors finish. This avoids per-query session creation overhead. - -### Graceful failure per collector - -Each collector silently skips on error. For example, `SHOW REPLICA STATUS` returns no rows on a non-replica server; `SHOW BINARY LOGS` fails if binary logging is disabled. The scrape still succeeds with whatever metrics were collected. - -## Expanded Metrics: 5 Data Sources - -### 1. SHOW GLOBAL STATUS (existing) - -- **Query**: `SHOW GLOBAL STATUS` -- **Prefix**: `mysql_global_status_` -- **Type logic**: Known gauge list (Threads_connected, Open_tables, buffer pool pages, Uptime, etc.); everything else `untyped` -- **Callback**: 2-column (Variable_name, Value), skip non-numeric values - -### 2. SHOW GLOBAL VARIABLES (new) - -- **Query**: `SHOW GLOBAL VARIABLES` -- **Prefix**: `mysql_global_variables_` -- **Type logic**: All `gauge` (configuration values are point-in-time snapshots) -- **Callback**: Same 2-column pattern as Global Status, skip non-numeric values (strings like `datadir` are discarded) -- **Key metrics exposed**: `max_connections`, `innodb_buffer_pool_size`, `innodb_log_file_size`, `table_open_cache`, `thread_cache_size`, etc. - -### 3. information_schema.INNODB_METRICS (new) - -- **Query**: `SELECT NAME, SUBSYSTEM, TYPE, COUNT FROM information_schema.INNODB_METRICS WHERE STATUS='enabled'` -- **Prefix**: `mysql_innodb_metrics_` -- **Type logic**: Uses InnoDB's own `TYPE` column: - - `counter` -> Prometheus `counter` - - `value`, `status_counter`, `set_owner`, `set_member` -> Prometheus `gauge` -- **Callback**: 4-column (NAME, SUBSYSTEM, TYPE, COUNT). Subsystem is not emitted as a label (to avoid cardinality); it's implicit in metric names. -- **Key metrics exposed**: ~200 detailed InnoDB internals covering buffer pool, transactions, locks, redo log, purge, DML operations, adaptive hash index, etc. This covers the quantitative data from `SHOW ENGINE INNODB STATUS` without text parsing. - -### 4. SHOW REPLICA STATUS (new) - -- **Query**: `SHOW REPLICA STATUS` -- **Prefix**: `mysql_replica_` -- **Type logic**: Per-field mapping -- **Callback**: Column-name-aware. During `field_metadata`, capture column names into a vector. During `get_string`, match against wanted fields. -- **Fields exported**: - -| MySQL Column | Prometheus Metric | Type | -|---|---|---| -| `Seconds_Behind_Source` | `mysql_replica_seconds_behind_source` | gauge | -| `Replica_IO_Running` | `mysql_replica_io_running` | gauge (1=Yes, 0=No) | -| `Replica_SQL_Running` | `mysql_replica_sql_running` | gauge (1=Yes, 0=No) | -| `Relay_Log_Space` | `mysql_replica_relay_log_space` | gauge | -| `Exec_Source_Log_Pos` | `mysql_replica_exec_source_log_pos` | gauge | -| `Read_Source_Log_Pos` | `mysql_replica_read_source_log_pos` | gauge | - -- If the query returns no rows (server is not a replica), nothing is emitted. - -### 5. SHOW BINARY LOGS (new) - -- **Query**: `SHOW BINARY LOGS` -- **Prefix**: `mysql_binlog_` -- **Type logic**: All `gauge` -- **Callback**: Accumulates across all result rows. Counts rows and sums `File_size` column. -- **Metrics exported**: - - `mysql_binlog_file_count` -- number of binary log files - - `mysql_binlog_size_bytes_total` -- total size of all binary log files -- If binary logging is disabled, query fails silently -- no metrics emitted. - -## Code Organization - -All changes within `plugin/prometheus_exporter/prometheus_exporter.cc`. Internal structure: - -``` -1. Includes, logging refs, system vars, context struct (existing) -2. Prometheus formatting helpers + gauge classification (existing, expanded) -3. Command service callbacks (reusable) (existing, refactored) -4. Collector: collect_global_status() (refactored from existing) -5. Collector: collect_global_variables() (new) -6. Collector: collect_innodb_metrics() (new) -7. Collector: collect_replica_status() (new) -8. Collector: collect_binlog() (new) -9. collect_metrics() orchestrator (refactored) -10. HTTP server (existing) -11. Plugin init/deinit, status vars, declaration (existing) -``` - -### Collector function signature - -```cpp -static void collect_(MYSQL_SESSION session, std::string &output); -``` - -Each collector takes the already-open session, runs its query, appends Prometheus-formatted lines to `output`. Returns silently on any error. - -### Callback reuse - -- **Global Status, Global Variables**: Reuse existing `MetricsCollectorCtx` and `prom_cbs` callbacks with configurable prefix and type-determination function passed via context. -- **InnoDB Metrics**: New context struct for 4-column results (NAME, SUBSYSTEM, TYPE, COUNT). -- **Replica Status**: New context struct with column-name-to-index mapping built during `field_metadata`. -- **Binary Logs**: New context struct that accumulates file count and total size. - -## Tests - -### Test inventory (7 tests) - -| Test | Purpose | .opt file needed | -|------|---------|:---:| -| `basic` | Install/uninstall, system vars, status vars. Add inline comments. | No | -| `metrics_endpoint` | HTTP endpoint, expanded to verify all 5 data source prefixes appear | Yes | -| `global_variables` | Verify `mysql_global_variables_max_connections` appears with type `gauge` | Yes | -| `innodb_metrics` | Verify `mysql_innodb_metrics_` lines appear with correct counter/gauge types | Yes | -| `replica_status` | Verify graceful absence: no `mysql_replica_` metrics on non-replica server | Yes | -| `binlog` | Verify `mysql_binlog_file_count` and `mysql_binlog_size_bytes_total` appear | Yes | -| `format_validation` | Perl block validates entire `/metrics` output structure (see below) | Yes | - -### Format validation test - -A perl block fetches the full `/metrics` output via curl and validates: -- Every `# TYPE ` line has `` in {counter, gauge, untyped} -- Every `# TYPE` line is immediately followed by a metric line starting with the same `` -- Every metric value line has format ` ` -- Metric names match `[a-z_][a-z0-9_]*` -- No blank values, no trailing whitespace on metric lines - -### Port allocation for parallel test safety - -Each test with a `.opt` file uses a different port to avoid conflicts when MTR runs tests in parallel: -- `metrics_endpoint`: 19104 -- `global_variables`: 19105 -- `innodb_metrics`: 19106 -- `replica_status`: 19107 -- `binlog`: 19108 -- `format_validation`: 19109 - -## Documentation - -### `plugin/prometheus_exporter/README.md` (full docs) - -1. **Overview** -- what the plugin is, philosophy (embedded, no sidecar) -2. **Architecture diagram** -- ASCII art: - -``` -┌─────────────────────────────────────────────────────┐ -│ 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 - │ - ┌────┴─────┐ - │Prometheus│ - │ Server │ - └──────────┘ -``` - -3. **Configuration** -- table of system variables (enabled, port, bind_address) with defaults and descriptions -4. **Metric namespaces** -- table of 5 prefixes, their source queries, and type classification logic -5. **Usage** -- `INSTALL PLUGIN` vs `--plugin-load`, example curl output snippet -6. **Metric type classification** -- how gauge/counter/untyped is determined per source -7. **Plugin status variables** -- the 3 self-monitoring metrics -8. **Limitations** -- no TLS, no auth (rely on bind_address), single-threaded scrape handling, Linux-only (POSIX sockets) - -### `Docs/prometheus_exporter.md` (pointer) - -Brief file pointing to the full docs: - -```markdown -# 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). -``` - -## Files Modified/Created - -| File | Action | -|------|--------| -| `plugin/prometheus_exporter/prometheus_exporter.cc` | Modified -- add 4 collectors, refactor collect_metrics(), expand gauge list | -| `plugin/prometheus_exporter/README.md` | Created -- full documentation with architecture diagram | -| `Docs/prometheus_exporter.md` | Created -- pointer to plugin docs | -| `mysql-test/suite/prometheus_exporter/t/basic.test` | Modified -- add inline comments | -| `mysql-test/suite/prometheus_exporter/t/metrics_endpoint.test` | Modified -- verify all 5 prefixes | -| `mysql-test/suite/prometheus_exporter/t/global_variables.test` | Created | -| `mysql-test/suite/prometheus_exporter/t/innodb_metrics.test` | Created | -| `mysql-test/suite/prometheus_exporter/t/replica_status.test` | Created | -| `mysql-test/suite/prometheus_exporter/t/binlog.test` | Created | -| `mysql-test/suite/prometheus_exporter/t/format_validation.test` | Created | -| `mysql-test/suite/prometheus_exporter/r/*.result` | Created/updated for all tests | -| `mysql-test/suite/prometheus_exporter/t/*-master.opt` | Created for new tests | - -No changes to any files outside `plugin/prometheus_exporter/`, `Docs/`, and `mysql-test/suite/prometheus_exporter/`. From f709eb036a95bdbc720ad69921666102d54ef9c5 Mon Sep 17 00:00:00 2001 From: Rene Cannao Date: Sun, 5 Apr 2026 06:13:13 +0000 Subject: [PATCH 10/16] fix(prometheus): address code review findings for security and correctness - Default bind address changed from 0.0.0.0 to 127.0.0.1 (no auth endpoint) - Increment errors_total in all four error handlers - Strict HTTP path matching (reject /metricsXYZ) - Add SO_RCVTIMEO on client sockets to prevent slow-client hangs - Full strtod/strtoll consumption checks to reject partial numeric parses - Check return values of security context functions - Fix README corrupted box-drawing characters - Test fixes: grep -c || true, orphaned TYPE detection, comment accuracy --- .../suite/prometheus_exporter/r/basic.result | 2 +- .../r/innodb_metrics.result | 2 +- .../r/replica_status.result | 1 - .../t/format_validation.test | 9 +++++ .../prometheus_exporter/t/innodb_metrics.test | 2 +- .../t/metrics_endpoint.test | 2 +- .../prometheus_exporter/t/replica_status.test | 2 +- plugin/prometheus_exporter/README.md | 18 ++++----- .../prometheus_exporter.cc | 39 ++++++++++++++----- 9 files changed, 52 insertions(+), 25 deletions(-) diff --git a/mysql-test/suite/prometheus_exporter/r/basic.result b/mysql-test/suite/prometheus_exporter/r/basic.result index 9e97311771e1..d6b6073cb6b4 100644 --- a/mysql-test/suite/prometheus_exporter/r/basic.result +++ b/mysql-test/suite/prometheus_exporter/r/basic.result @@ -3,7 +3,7 @@ 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 0.0.0.0 +prometheus_exporter_bind_address 127.0.0.1 prometheus_exporter_enabled OFF prometheus_exporter_port 9104 # Verify status variables exist (all zero when disabled) diff --git a/mysql-test/suite/prometheus_exporter/r/innodb_metrics.result b/mysql-test/suite/prometheus_exporter/r/innodb_metrics.result index e12604fe6259..d1f4c7b99b09 100644 --- a/mysql-test/suite/prometheus_exporter/r/innodb_metrics.result +++ b/mysql-test/suite/prometheus_exporter/r/innodb_metrics.result @@ -2,7 +2,7 @@ # 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 counter type metric exists +# 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/replica_status.result b/mysql-test/suite/prometheus_exporter/r/replica_status.result index 4494f4f0df83..d3e029957605 100644 --- a/mysql-test/suite/prometheus_exporter/r/replica_status.result +++ b/mysql-test/suite/prometheus_exporter/r/replica_status.result @@ -1,5 +1,4 @@ # On a non-replica server, no mysql_replica_ metrics should appear 0 -0 # But other metrics should still be present # TYPE mysql_global_status_uptime gauge diff --git a/mysql-test/suite/prometheus_exporter/t/format_validation.test b/mysql-test/suite/prometheus_exporter/t/format_validation.test index 09548e3aae31..f84e3a30f350 100644 --- a/mysql-test/suite/prometheus_exporter/t/format_validation.test +++ b/mysql-test/suite/prometheus_exporter/t/format_validation.test @@ -31,6 +31,10 @@ for (my $i = 0; $i < scalar @lines; $i++) { # 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 { @@ -70,6 +74,11 @@ for (my $i = 0; $i < scalar @lines; $i++) { } } +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) { diff --git a/mysql-test/suite/prometheus_exporter/t/innodb_metrics.test b/mysql-test/suite/prometheus_exporter/t/innodb_metrics.test index c1fdfb65a9b0..552b06b26429 100644 --- a/mysql-test/suite/prometheus_exporter/t/innodb_metrics.test +++ b/mysql-test/suite/prometheus_exporter/t/innodb_metrics.test @@ -3,7 +3,7 @@ --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 counter type metric exists +--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) diff --git a/mysql-test/suite/prometheus_exporter/t/metrics_endpoint.test b/mysql-test/suite/prometheus_exporter/t/metrics_endpoint.test index 4f5c69c90b59..670d1717f118 100644 --- a/mysql-test/suite/prometheus_exporter/t/metrics_endpoint.test +++ b/mysql-test/suite/prometheus_exporter/t/metrics_endpoint.test @@ -3,7 +3,7 @@ # # Verifies: # - HTTP endpoint serves Prometheus text format -# - All 5 collector prefixes appear in output +# - 4 of 5 collector prefixes appear in output (replica not testable without replica setup) # - 404 returned for unknown paths # - Scrape counter increments # ============================================================================= diff --git a/mysql-test/suite/prometheus_exporter/t/replica_status.test b/mysql-test/suite/prometheus_exporter/t/replica_status.test index f81d62c55004..45fcb07065b3 100644 --- a/mysql-test/suite/prometheus_exporter/t/replica_status.test +++ b/mysql-test/suite/prometheus_exporter/t/replica_status.test @@ -1,7 +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_" || echo "0" +--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/plugin/prometheus_exporter/README.md b/plugin/prometheus_exporter/README.md index 7df8d6feaa06..6ed13c5d0839 100644 --- a/plugin/prometheus_exporter/README.md +++ b/plugin/prometheus_exporter/README.md @@ -16,9 +16,9 @@ Prometheus text exposition format, and returns them over HTTP. ┌─────────────────────────────────────────────────────┐ │ VillageSQL Server │ │ │ -│ ┌──────────────���───────────────────────────────┐ │ +│ ┌──────────────────────────────────────────────┐ │ │ │ prometheus_exporter plugin │ │ -│ │ �� │ +│ │ │ │ │ │ ┌──────────────┐ ┌────────────────────┐ │ │ │ │ │ HTTP Listener │ │ collect_metrics() │ │ │ │ │ │ (poll loop) │───>│ │ │ │ @@ -26,8 +26,8 @@ Prometheus text exposition format, and returns them over HTTP. │ │ └──────────────┘ │ │ │ │ │ │ │ │ ┌─────▼─────────┐ │ │ │ │ │ │ │ SHOW GLOBAL │ │ │ │ -│ │ │ │ STATUS │ │ �� │ -│ │ │ ├─────────────���─┤ │ │ │ +│ │ │ │ STATUS │ │ │ │ +│ │ │ ├───────────────┤ │ │ │ │ │ │ │ SHOW GLOBAL │ │ │ │ │ │ │ │ VARIABLES │ │ │ │ │ │ │ ├───────────────┤ │ │ │ @@ -38,17 +38,17 @@ Prometheus text exposition format, and returns them over HTTP. │ │ │ ├───────────────┤ │ │ │ │ │ │ │ SHOW BINARY │ │ │ │ │ │ │ │ LOGS │ │ │ │ -│ │ │ └─────────────���─┘ │ │ │ -│ │ └─────────���──────────┘ │ │ +│ │ │ └───────────────┘ │ │ │ +│ │ └────────────────────┘ │ │ │ └──────────────────────────────────────────────┘ │ -└─────────────────────────────────────���───────────────┘ +└─────────────────────────────────────────────────────┘ ▲ │ HTTP GET /metrics (every 15-60s) │ ┌────┴─────┐ │Prometheus│ │ Server │ - └──────���───┘ + └──────────┘ ``` Key design choice: the plugin executes standard SQL queries via the @@ -64,7 +64,7 @@ server restart to change). |----------|------|---------|-------------| | `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 | 0.0.0.0 | IP address to bind to | +| `prometheus_exporter_bind_address` | STRING | 127.0.0.1 | IP address to bind to | ## Usage diff --git a/plugin/prometheus_exporter/prometheus_exporter.cc b/plugin/prometheus_exporter/prometheus_exporter.cc index 1a94978c7880..0583ffe008dc 100644 --- a/plugin/prometheus_exporter/prometheus_exporter.cc +++ b/plugin/prometheus_exporter/prometheus_exporter.cc @@ -77,8 +77,8 @@ 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 0.0.0.0.", - nullptr, nullptr, "0.0.0.0"); + "endpoint. Default 127.0.0.1.", + nullptr, nullptr, "127.0.0.1"); static SYS_VAR *prom_system_vars[] = { MYSQL_SYSVAR(enabled), @@ -203,9 +203,8 @@ static int prom_end_row(void *ctx) { // Try to parse as a number; skip non-numeric values (ON/OFF etc.) char *end = nullptr; - double val = strtod(mc->current_value.c_str(), &end); - if (end == mc->current_value.c_str()) return 0; // not numeric - (void)val; // we use the string representation directly + 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; @@ -258,6 +257,8 @@ static void prom_handle_ok(void *, uint, uint, ulonglong, ulonglong, static void prom_handle_error(void *ctx, uint, const char *, const char *) { auto *mc = static_cast(ctx); mc->error = true; + if (g_ctx != nullptr) + g_ctx->errors_total.fetch_add(1, std::memory_order_relaxed); } static void prom_shutdown(void *, int) {} @@ -352,6 +353,8 @@ static int innodb_get_string(void *ctx, const char *value, size_t length, static void innodb_handle_error(void *ctx, uint, const char *, const char *) { auto *mc = static_cast(ctx); mc->error = true; + if (g_ctx != nullptr) + g_ctx->errors_total.fetch_add(1, std::memory_order_relaxed); } static const struct st_command_service_cbs innodb_cbs = { @@ -482,7 +485,7 @@ static int replica_end_row(void *ctx) { // Check if numeric char *end = nullptr; strtod(val.c_str(), &end); - if (end == val.c_str()) continue; // not numeric, skip + if (end == val.c_str() || *end != '\0') continue; // not numeric, skip value_str = val; } @@ -501,6 +504,8 @@ static int replica_end_row(void *ctx) { static void replica_handle_error(void *ctx, uint, const char *, const char *) { auto *rc = static_cast(ctx); rc->error = true; + if (g_ctx != nullptr) + g_ctx->errors_total.fetch_add(1, std::memory_order_relaxed); } static const struct st_command_service_cbs replica_cbs = { @@ -569,7 +574,7 @@ static int binlog_end_row(void *ctx) { if (!bc->current_size.empty()) { char *end = nullptr; long long sz = strtoll(bc->current_size.c_str(), &end, 10); - if (end != bc->current_size.c_str()) { + if (end != bc->current_size.c_str() && *end == '\0') { bc->total_size += sz; } } @@ -605,6 +610,8 @@ static void binlog_handle_ok(void *ctx, uint, uint, ulonglong, ulonglong, static void binlog_handle_error(void *ctx, uint, const char *, const char *) { auto *bc = static_cast(ctx); bc->error = true; + if (g_ctx != nullptr) + g_ctx->errors_total.fetch_add(1, std::memory_order_relaxed); } static const struct st_command_service_cbs binlog_cbs = { @@ -702,8 +709,12 @@ static std::string collect_metrics() { // Switch to root security context MYSQL_SECURITY_CONTEXT sc; - thd_get_security_context(srv_session_info_get_thd(session), &sc); - security_context_lookup(sc, "root", "localhost", "127.0.0.1", ""); + if (thd_get_security_context(srv_session_info_get_thd(session), &sc) || + security_context_lookup(sc, "root", "localhost", "127.0.0.1", "")) { + srv_session_close(session); + srv_session_deinit_thread(); + return "# Failed to set security context\n"; + } std::string output; collect_global_status(session, output); @@ -774,6 +785,12 @@ static void *prometheus_listener_thread(void *arg) { int client_fd = accept(ctx->listen_fd, nullptr, nullptr); if (client_fd < 0) continue; + // 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)); + // Read HTTP request char buf[4096]; ssize_t n = read(client_fd, buf, sizeof(buf) - 1); @@ -783,7 +800,9 @@ static void *prometheus_listener_thread(void *arg) { } buf[n] = '\0'; - if (strncmp(buf, "GET /metrics", 12) == 0) { + if (strncmp(buf, "GET /metrics", 12) == 0 && + (buf[12] == ' ' || buf[12] == '?' || buf[12] == '\r' || + buf[12] == '\0')) { ctx->requests_total.fetch_add(1, std::memory_order_relaxed); auto start = std::chrono::steady_clock::now(); From 8efd77f2d2a5c09422540b22faf5d7eb6b751f44 Mon Sep 17 00:00:00 2001 From: Rene Cannao Date: Fri, 10 Apr 2026 12:54:52 +0000 Subject: [PATCH 11/16] fix(prometheus): critical safety bugs from code review - Move atomic counters out of PrometheusContext to file-scope globals, eliminating the use-after-free race between SHOW STATUS callbacks and plugin deinit. Use memcpy instead of reinterpret_cast to avoid alignment issues in SHOW_FUNC callbacks. - Use eventfd as wakeup mechanism instead of closing listen_fd from another thread (fixes fd-recycle hazard). close() now happens after join(), so the listener never sees closed fds. - Use memory_order_acquire/release on shutdown_requested flag. - Add deinit_logging_service_for_plugin to all init failure paths to avoid leaking the logging service registration. - NULL/empty check for bind_address to prevent crash on inet_pton(NULL). --- .../prometheus_exporter.cc | 129 ++++++++++++------ 1 file changed, 86 insertions(+), 43 deletions(-) diff --git a/plugin/prometheus_exporter/prometheus_exporter.cc b/plugin/prometheus_exporter/prometheus_exporter.cc index 0583ffe008dc..2b5c7bc7e1f8 100644 --- a/plugin/prometheus_exporter/prometheus_exporter.cc +++ b/plugin/prometheus_exporter/prometheus_exporter.cc @@ -39,7 +39,10 @@ #include #include +#include + #include +#include #include #include #include @@ -87,6 +90,17 @@ static SYS_VAR *prom_system_vars[] = { nullptr, }; +// ----------------------------------------------------------------------- +// Module-scope counters +// ----------------------------------------------------------------------- + +// 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}; + // ----------------------------------------------------------------------- // Plugin context // ----------------------------------------------------------------------- @@ -94,20 +108,15 @@ static SYS_VAR *prom_system_vars[] = { struct PrometheusContext { my_thread_handle listener_thread; int listen_fd; + int wakeup_fd; std::atomic shutdown_requested; void *plugin_ref; - std::atomic requests_total; - std::atomic errors_total; - std::atomic last_scrape_duration_us; - PrometheusContext() : listen_fd(-1), + wakeup_fd(-1), shutdown_requested(false), - plugin_ref(nullptr), - requests_total(0), - errors_total(0), - last_scrape_duration_us(0) {} + plugin_ref(nullptr) {} }; static PrometheusContext *g_ctx = nullptr; @@ -257,8 +266,7 @@ static void prom_handle_ok(void *, uint, uint, ulonglong, ulonglong, static void prom_handle_error(void *ctx, uint, const char *, const char *) { auto *mc = static_cast(ctx); mc->error = true; - if (g_ctx != nullptr) - g_ctx->errors_total.fetch_add(1, std::memory_order_relaxed); + g_errors_total.fetch_add(1, std::memory_order_relaxed); } static void prom_shutdown(void *, int) {} @@ -353,8 +361,7 @@ static int innodb_get_string(void *ctx, const char *value, size_t length, static void innodb_handle_error(void *ctx, uint, const char *, const char *) { auto *mc = static_cast(ctx); mc->error = true; - if (g_ctx != nullptr) - g_ctx->errors_total.fetch_add(1, std::memory_order_relaxed); + g_errors_total.fetch_add(1, std::memory_order_relaxed); } static const struct st_command_service_cbs innodb_cbs = { @@ -504,8 +511,7 @@ static int replica_end_row(void *ctx) { static void replica_handle_error(void *ctx, uint, const char *, const char *) { auto *rc = static_cast(ctx); rc->error = true; - if (g_ctx != nullptr) - g_ctx->errors_total.fetch_add(1, std::memory_order_relaxed); + g_errors_total.fetch_add(1, std::memory_order_relaxed); } static const struct st_command_service_cbs replica_cbs = { @@ -610,8 +616,7 @@ static void binlog_handle_ok(void *ctx, uint, uint, ulonglong, ulonglong, static void binlog_handle_error(void *ctx, uint, const char *, const char *) { auto *bc = static_cast(ctx); bc->error = true; - if (g_ctx != nullptr) - g_ctx->errors_total.fetch_add(1, std::memory_order_relaxed); + g_errors_total.fetch_add(1, std::memory_order_relaxed); } static const struct st_command_service_cbs binlog_cbs = { @@ -734,6 +739,10 @@ static std::string collect_metrics() { // ----------------------------------------------------------------------- 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; @@ -774,16 +783,32 @@ static void write_full(int fd, const char *buf, size_t len) { static void *prometheus_listener_thread(void *arg) { auto *ctx = static_cast(arg); - while (!ctx->shutdown_requested.load(std::memory_order_relaxed)) { - struct pollfd pfd; - pfd.fd = ctx->listen_fd; - pfd.events = POLLIN; + 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 + } - int ret = poll(&pfd, 1, 1000); - if (ret <= 0) continue; + // 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) continue; + 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; @@ -803,13 +828,13 @@ static void *prometheus_listener_thread(void *arg) { if (strncmp(buf, "GET /metrics", 12) == 0 && (buf[12] == ' ' || buf[12] == '?' || buf[12] == '\r' || buf[12] == '\0')) { - ctx->requests_total.fetch_add(1, std::memory_order_relaxed); + 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); - ctx->last_scrape_duration_us.store( + g_last_scrape_duration_us.store( static_cast(elapsed.count()), std::memory_order_relaxed); std::string response = @@ -854,16 +879,29 @@ static int prometheus_exporter_init(void *p) { } g_ctx = new (std::nothrow) PrometheusContext(); - if (g_ctx == nullptr) return 1; + if (g_ctx == nullptr) { + deinit_logging_service_for_plugin(®_srv, &log_bi, &log_bs); + return 1; + } g_ctx->plugin_ref = p; g_ctx->listen_fd = setup_listen_socket(prom_bind_address, prom_port); if (g_ctx->listen_fd < 0) { LogPluginErr(ERROR_LEVEL, ER_LOG_PRINTF_MSG, "Prometheus exporter: failed to bind to %s:%u", - prom_bind_address, prom_port); + prom_bind_address ? prom_bind_address : "(null)", prom_port); delete g_ctx; g_ctx = nullptr; + deinit_logging_service_for_plugin(®_srv, &log_bi, &log_bs); + return 1; + } + + g_ctx->wakeup_fd = eventfd(0, EFD_CLOEXEC); + if (g_ctx->wakeup_fd < 0) { + close(g_ctx->listen_fd); + delete g_ctx; + g_ctx = nullptr; + deinit_logging_service_for_plugin(®_srv, &log_bi, &log_bs); return 1; } @@ -876,8 +914,10 @@ static int prometheus_exporter_init(void *p) { LogPluginErr(ERROR_LEVEL, ER_LOG_PRINTF_MSG, "Prometheus exporter: failed to create listener thread"); close(g_ctx->listen_fd); + close(g_ctx->wakeup_fd); delete g_ctx; g_ctx = nullptr; + deinit_logging_service_for_plugin(®_srv, &log_bi, &log_bs); return 1; } @@ -894,12 +934,20 @@ static int prometheus_exporter_deinit(void *p) { auto *ctx = static_cast(plugin->data); if (ctx != nullptr) { - ctx->shutdown_requested.store(true, std::memory_order_relaxed); - if (ctx->listen_fd >= 0) close(ctx->listen_fd); + 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; g_ctx = nullptr; } @@ -915,32 +963,27 @@ static int prometheus_exporter_deinit(void *p) { static int show_requests_total(MYSQL_THD, SHOW_VAR *var, char *buff) { var->type = SHOW_LONGLONG; var->value = buff; - *reinterpret_cast(buff) = - g_ctx != nullptr - ? static_cast(g_ctx->requests_total.load( - std::memory_order_relaxed)) - : 0; + 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; - *reinterpret_cast(buff) = - g_ctx != nullptr ? static_cast( - g_ctx->errors_total.load(std::memory_order_relaxed)) - : 0; + 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; - *reinterpret_cast(buff) = - g_ctx != nullptr - ? static_cast(g_ctx->last_scrape_duration_us.load( - std::memory_order_relaxed)) - : 0; + longlong v = static_cast( + g_last_scrape_duration_us.load(std::memory_order_relaxed)); + memcpy(buff, &v, sizeof(v)); return 0; } From f4041d3aed672c41cc6b941ff9806b21682a151a Mon Sep 17 00:00:00 2001 From: Rene Cannao Date: Fri, 10 Apr 2026 12:56:42 +0000 Subject: [PATCH 12/16] fix(prometheus): HTTP robustness improvements - Add SO_SNDTIMEO (5s) on accepted client sockets alongside existing SO_RCVTIMEO. Prevents listener thread from blocking forever when a client completes TCP handshake but never reads. - Switch write_full from write() to send() with MSG_NOSIGNAL to avoid SIGPIPE on half-closed connections, and retry on EINTR. - Loop reading HTTP request until \r\n\r\n delimiter or buffer full, instead of a single read() that may return a partial request across TCP segments. Handles EINTR correctly. - Length check before indexing buf[12] in path matcher. --- .../prometheus_exporter.cc | 34 ++++++++++++++++--- 1 file changed, 29 insertions(+), 5 deletions(-) diff --git a/plugin/prometheus_exporter/prometheus_exporter.cc b/plugin/prometheus_exporter/prometheus_exporter.cc index 2b5c7bc7e1f8..010f4b096cc6 100644 --- a/plugin/prometheus_exporter/prometheus_exporter.cc +++ b/plugin/prometheus_exporter/prometheus_exporter.cc @@ -774,12 +774,36 @@ static int setup_listen_socket(const char *bind_addr, unsigned int port) { static void write_full(int fd, const char *buf, size_t len) { size_t written = 0; while (written < len) { - ssize_t n = write(fd, buf + written, len - written); - if (n <= 0) break; + 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); @@ -815,17 +839,17 @@ static void *prometheus_listener_thread(void *arg) { 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(client_fd, buf, sizeof(buf) - 1); + ssize_t n = read_http_request(client_fd, buf, sizeof(buf)); if (n <= 0) { close(client_fd); continue; } - buf[n] = '\0'; - if (strncmp(buf, "GET /metrics", 12) == 0 && + 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); From dccbd9448183425aaf1f9614b9f58276a056581f Mon Sep 17 00:00:00 2001 From: Rene Cannao Date: Fri, 10 Apr 2026 13:03:29 +0000 Subject: [PATCH 13/16] fix(prometheus): srv_session lifecycle and configurable security user - Move srv_session_init_thread/deinit_thread to listener thread lifetime instead of per-scrape. Per MySQL service contract, these must be called once per physical thread, not per request. - Add prometheus_exporter_security_user sysvar to make the internal user configurable (was hardcoded to "root"). Default remains "root" for backward compatibility. Operators can switch to a least-privilege account (e.g. one granted PROCESS, REPLICATION CLIENT, and SELECT on information_schema) by setting this variable at startup. Note that mysql.session@localhost does NOT have PROCESS by default, so cannot read information_schema.INNODB_METRICS without an additional grant. - Improve error message when security context setup fails. --- .../suite/prometheus_exporter/r/basic.result | 1 + .../prometheus_exporter.cc | 41 ++++++++++++++----- 2 files changed, 32 insertions(+), 10 deletions(-) diff --git a/mysql-test/suite/prometheus_exporter/r/basic.result b/mysql-test/suite/prometheus_exporter/r/basic.result index d6b6073cb6b4..2d1c7f79dc7b 100644 --- a/mysql-test/suite/prometheus_exporter/r/basic.result +++ b/mysql-test/suite/prometheus_exporter/r/basic.result @@ -6,6 +6,7 @@ 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 diff --git a/plugin/prometheus_exporter/prometheus_exporter.cc b/plugin/prometheus_exporter/prometheus_exporter.cc index 010f4b096cc6..cf6e7fc2c7ef 100644 --- a/plugin/prometheus_exporter/prometheus_exporter.cc +++ b/plugin/prometheus_exporter/prometheus_exporter.cc @@ -63,6 +63,7 @@ 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, @@ -83,10 +84,21 @@ static MYSQL_SYSVAR_STR(bind_address, prom_bind_address, "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, }; @@ -702,23 +714,23 @@ static std::string collect_metrics() { return "# Server not available\n"; } - if (srv_session_init_thread(g_ctx->plugin_ref) != 0) { - return "# Failed to init session thread\n"; - } - MYSQL_SESSION session = srv_session_open(nullptr, nullptr); if (session == nullptr) { - srv_session_deinit_thread(); return "# Failed to open session\n"; } - // Switch to root security context + // 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, "root", "localhost", "127.0.0.1", "")) { + security_context_lookup(sc, user, "localhost", "127.0.0.1", "")) { srv_session_close(session); - srv_session_deinit_thread(); - return "# Failed to set security context\n"; + return "# Failed to set security context (user missing or lacks " + "privileges?)\n"; } std::string output; @@ -729,7 +741,6 @@ static std::string collect_metrics() { collect_binlog(session, output); srv_session_close(session); - srv_session_deinit_thread(); return output; } @@ -807,6 +818,15 @@ static ssize_t read_http_request(int fd, char *buf, size_t max_len) { 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; @@ -882,6 +902,7 @@ static void *prometheus_listener_thread(void *arg) { close(client_fd); } + srv_session_deinit_thread(); return nullptr; } From 9c388765b45be3e103d1ba3b4e199d96dde6e912 Mon Sep 17 00:00:00 2001 From: Rene Cannao Date: Fri, 10 Apr 2026 13:06:20 +0000 Subject: [PATCH 14/16] fix(prometheus): medium-severity correctness and hardening fixes - Binlog collector: fully validate numeric parsing (reject "123abc"), check for negative values and overflow before accumulating. - Replica collector: require full-string numeric consumption in strtod check, matching the fix previously applied to global_status. - Log a WARNING at plugin init when bound to a non-loopback address, alerting operators to the security implications of exposing the unauthenticated /metrics endpoint to the network. - Mark unused parameter in global_variables_type with [[maybe_unused]]. --- .../prometheus_exporter.cc | 29 +++++++++++++++---- 1 file changed, 23 insertions(+), 6 deletions(-) diff --git a/plugin/prometheus_exporter/prometheus_exporter.cc b/plugin/prometheus_exporter/prometheus_exporter.cc index cf6e7fc2c7ef..1cdf76e4f08e 100644 --- a/plugin/prometheus_exporter/prometheus_exporter.cc +++ b/plugin/prometheus_exporter/prometheus_exporter.cc @@ -44,6 +44,7 @@ #include #include #include +#include #include #include #include @@ -501,10 +502,11 @@ static int replica_end_row(void *ctx) { if (wanted.is_bool) { value_str = (val == "Yes") ? "1" : "0"; } else { - // Check if numeric + // Check if numeric -- require full-string consumption + const char *start = val.c_str(); char *end = nullptr; - strtod(val.c_str(), &end); - if (end == val.c_str() || *end != '\0') continue; // not numeric, skip + strtod(start, &end); + if (end == start || *end != '\0') continue; // not numeric, skip value_str = val; } @@ -590,9 +592,11 @@ 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(bc->current_size.c_str(), &end, 10); - if (end != bc->current_size.c_str() && *end == '\0') { + long long sz = strtoll(start, &end, 10); + if (end != start && *end == '\0' && sz >= 0 && + bc->total_size <= LLONG_MAX - sz) { bc->total_size += sz; } } @@ -701,7 +705,9 @@ static void collect_global_status(MYSQL_SESSION session, std::string &output) { "mysql_global_status_", global_status_type); } -static const char *global_variables_type(const char *) { return "gauge"; } +static const char *global_variables_type([[maybe_unused]] const char *name) { + return "gauge"; +} static void collect_global_variables(MYSQL_SESSION session, std::string &output) { @@ -970,6 +976,17 @@ static int prometheus_exporter_init(void *p) { "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 = g_ctx; return 0; } From d96ee346f5bb248661f252785c4c495662636c12 Mon Sep 17 00:00:00 2001 From: Rene Cannao Date: Fri, 10 Apr 2026 13:10:06 +0000 Subject: [PATCH 15/16] style(prometheus): code quality cleanup - Remove section separator comments per CLAUDE.md guideline - Remove g_ctx/plugin->data redundancy, keeping only one source of truth for the plugin context pointer --- .../prometheus_exporter.cc | 95 ++++--------------- 1 file changed, 17 insertions(+), 78 deletions(-) diff --git a/plugin/prometheus_exporter/prometheus_exporter.cc b/plugin/prometheus_exporter/prometheus_exporter.cc index 1cdf76e4f08e..46c8394feb48 100644 --- a/plugin/prometheus_exporter/prometheus_exporter.cc +++ b/plugin/prometheus_exporter/prometheus_exporter.cc @@ -49,18 +49,10 @@ #include #include -// ----------------------------------------------------------------------- -// Logging service references -// ----------------------------------------------------------------------- - static SERVICE_TYPE(registry) *reg_srv = nullptr; SERVICE_TYPE(log_builtins) *log_bi = nullptr; SERVICE_TYPE(log_builtins_string) *log_bs = nullptr; -// ----------------------------------------------------------------------- -// System variables -// ----------------------------------------------------------------------- - static bool prom_enabled = false; static unsigned int prom_port = 9104; static char *prom_bind_address = nullptr; @@ -103,10 +95,6 @@ static SYS_VAR *prom_system_vars[] = { nullptr, }; -// ----------------------------------------------------------------------- -// Module-scope counters -// ----------------------------------------------------------------------- - // Module-scope counters. Lifetime independent of the context so that // SHOW STATUS callbacks can safely read them even during plugin // install/uninstall races. @@ -114,10 +102,6 @@ static std::atomic g_requests_total{0}; static std::atomic g_errors_total{0}; static std::atomic g_last_scrape_duration_us{0}; -// ----------------------------------------------------------------------- -// Plugin context -// ----------------------------------------------------------------------- - struct PrometheusContext { my_thread_handle listener_thread; int listen_fd; @@ -132,12 +116,6 @@ struct PrometheusContext { plugin_ref(nullptr) {} }; -static PrometheusContext *g_ctx = nullptr; - -// ----------------------------------------------------------------------- -// Gauge variable classification -// ----------------------------------------------------------------------- - static const char *gauge_variables[] = { "Threads_connected", "Threads_running", @@ -178,10 +156,6 @@ static bool is_gauge(const char *name) { return false; } -// ----------------------------------------------------------------------- -// Metrics collection via srv_session + command_service -// ----------------------------------------------------------------------- - typedef const char *(*type_fn_t)(const char *name); struct MetricsCollectorCtx { @@ -307,10 +281,6 @@ static const struct st_command_service_cbs prom_cbs = { nullptr, // connection_alive }; -// ----------------------------------------------------------------------- -// InnoDB metrics collection (4-column result set) -// ----------------------------------------------------------------------- - struct InnodbMetricsCtx { std::string *output; std::string current_name; @@ -419,10 +389,6 @@ static void collect_innodb_metrics(MYSQL_SESSION session, std::string &output) { CS_TEXT_REPRESENTATION, &mc); } -// ----------------------------------------------------------------------- -// SHOW REPLICA STATUS collection (column-name-aware parsing) -// ----------------------------------------------------------------------- - struct ReplicaStatusCtx { std::string *output; std::vector col_names; @@ -568,10 +534,6 @@ static void collect_replica_status(MYSQL_SESSION session, std::string &output) { CS_TEXT_REPRESENTATION, &rc); } -// ----------------------------------------------------------------------- -// SHOW BINARY LOGS collection -// ----------------------------------------------------------------------- - struct BinlogCtx { std::string *output; int col_index; @@ -676,10 +638,6 @@ static void collect_binlog(MYSQL_SESSION session, std::string &output) { CS_TEXT_REPRESENTATION, &bc); } -// ----------------------------------------------------------------------- -// Generic name/value query collection -// ----------------------------------------------------------------------- - static void collect_name_value_query(MYSQL_SESSION session, std::string &output, const char *query, const char *prefix, type_fn_t type_fn) { @@ -751,10 +709,6 @@ static std::string collect_metrics() { return output; } -// ----------------------------------------------------------------------- -// HTTP server -// ----------------------------------------------------------------------- - static int setup_listen_socket(const char *bind_addr, unsigned int port) { if (bind_addr == nullptr || *bind_addr == '\0') { return -1; @@ -912,10 +866,6 @@ static void *prometheus_listener_thread(void *arg) { return nullptr; } -// ----------------------------------------------------------------------- -// Plugin init / deinit -// ----------------------------------------------------------------------- - static int prometheus_exporter_init(void *p) { auto *plugin = static_cast(p); @@ -929,29 +879,27 @@ static int prometheus_exporter_init(void *p) { return 0; } - g_ctx = new (std::nothrow) PrometheusContext(); - if (g_ctx == nullptr) { + auto *ctx = new (std::nothrow) PrometheusContext(); + if (ctx == nullptr) { deinit_logging_service_for_plugin(®_srv, &log_bi, &log_bs); return 1; } - g_ctx->plugin_ref = p; + ctx->plugin_ref = p; - g_ctx->listen_fd = setup_listen_socket(prom_bind_address, prom_port); - if (g_ctx->listen_fd < 0) { + 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 g_ctx; - g_ctx = nullptr; + delete ctx; deinit_logging_service_for_plugin(®_srv, &log_bi, &log_bs); return 1; } - g_ctx->wakeup_fd = eventfd(0, EFD_CLOEXEC); - if (g_ctx->wakeup_fd < 0) { - close(g_ctx->listen_fd); - delete g_ctx; - g_ctx = nullptr; + 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; } @@ -960,14 +908,13 @@ static int prometheus_exporter_init(void *p) { my_thread_attr_init(&attr); my_thread_attr_setdetachstate(&attr, MY_THREAD_CREATE_JOINABLE); - if (my_thread_create(&g_ctx->listener_thread, &attr, - prometheus_listener_thread, g_ctx) != 0) { + 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(g_ctx->listen_fd); - close(g_ctx->wakeup_fd); - delete g_ctx; - g_ctx = nullptr; + close(ctx->listen_fd); + close(ctx->wakeup_fd); + delete ctx; deinit_logging_service_for_plugin(®_srv, &log_bi, &log_bs); return 1; } @@ -987,7 +934,7 @@ static int prometheus_exporter_init(void *p) { prom_bind_address, prom_port); } - plugin->data = g_ctx; + plugin->data = ctx; return 0; } @@ -1011,17 +958,13 @@ static int prometheus_exporter_deinit(void *p) { if (ctx->wakeup_fd >= 0) close(ctx->wakeup_fd); delete ctx; - g_ctx = nullptr; + plugin->data = nullptr; } deinit_logging_service_for_plugin(®_srv, &log_bi, &log_bs); return 0; } -// ----------------------------------------------------------------------- -// Plugin status variables -// ----------------------------------------------------------------------- - static int show_requests_total(MYSQL_THD, SHOW_VAR *var, char *buff) { var->type = SHOW_LONGLONG; var->value = buff; @@ -1062,10 +1005,6 @@ static SHOW_VAR prom_status_vars[] = { {nullptr, nullptr, SHOW_UNDEF, SHOW_SCOPE_UNDEF}, }; -// ----------------------------------------------------------------------- -// Plugin declaration -// ----------------------------------------------------------------------- - static struct st_mysql_daemon prometheus_exporter_descriptor = { MYSQL_DAEMON_INTERFACE_VERSION}; From d92a1fe775ebb9b8f3df48a1fd4ae0a5c942f29c Mon Sep 17 00:00:00 2001 From: Rene Cannao Date: Fri, 10 Apr 2026 13:12:49 +0000 Subject: [PATCH 16/16] test(prometheus): strengthen test coverage - Add scrape_counter.test: captures requests_total before and after a fixed number of curl scrapes, asserts exact delta. Also verifies scrape_duration_microseconds > 0 and errors_total == 0. Catches regressions where status counters silently fail to update. - Remove format_validation carve-out that exempted global_variables and _time metrics from numeric validation. The plugin's prom_end_row already filters non-numeric values, so the validator should enforce the invariant strictly. Extend regex to accept NaN/+Inf/-Inf per Prometheus spec. --- .../r/scrape_counter.result | 23 ++++++++++++ .../t/format_validation.test | 14 +++----- .../t/scrape_counter-master.opt | 1 + .../prometheus_exporter/t/scrape_counter.test | 35 +++++++++++++++++++ 4 files changed, 64 insertions(+), 9 deletions(-) create mode 100644 mysql-test/suite/prometheus_exporter/r/scrape_counter.result create mode 100644 mysql-test/suite/prometheus_exporter/t/scrape_counter-master.opt create mode 100644 mysql-test/suite/prometheus_exporter/t/scrape_counter.test 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/format_validation.test b/mysql-test/suite/prometheus_exporter/t/format_validation.test index f84e3a30f350..c09ca3c0a9e5 100644 --- a/mysql-test/suite/prometheus_exporter/t/format_validation.test +++ b/mysql-test/suite/prometheus_exporter/t/format_validation.test @@ -5,8 +5,7 @@ # - 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_]* -# - Numeric metric values are well-formed -# - String-valued variables metrics are accepted (info-style gauges) +# - Every metric value is numeric or NaN/+Inf/-Inf per Prometheus spec # ============================================================================= --source include/not_windows.inc @@ -60,13 +59,10 @@ for (my $i = 0; $i < scalar @lines; $i++) { } $expect_metric_name = undef; - # Check value is numeric (string-valued variables metrics are allowed) - unless ($value =~ /^-?[0-9]+(\.[0-9]+)?([eE][+-]?[0-9]+)?$/) { - # Global variables and some status metrics export string values - unless ($name =~ /^mysql_global_variables_/ || $name =~ /_time$/) { - print "FORMAT ERROR: non-numeric value '$value' for metric '$name'\n"; - $errors++; - } + # 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"; 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';