diff --git a/CHANGELOG.md b/CHANGELOG.md index f24d4e8..a802ad9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,25 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.1.0] - 2026-02-02 + +### Added + +- HTTP request metrics: + - `wordpress_http_requests_total` - Counter of HTTP requests by method, status code, and endpoint + - `wordpress_http_request_duration_seconds` - Histogram of request durations +- Database query metrics: + - `wordpress_db_queries_total` - Counter of database queries by endpoint + - `wordpress_db_query_duration_seconds` - Histogram of query durations (requires SAVEQUERIES) +- RuntimeCollector class for collecting metrics during WordPress request lifecycle +- New settings options for enabling/disabling runtime metrics +- Translation files (.pot, .po, .mo) for German (Switzerland) + +### Changed + +- Metrics are now categorized into static metrics (users, posts, etc.) and runtime metrics (HTTP, database) +- Runtime metrics only collected when explicitly enabled and license is valid + ## [0.0.2] - 2026-02-01 ### Fixed diff --git a/CLAUDE.md b/CLAUDE.md index dc7857d..e5e9d2e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -14,6 +14,7 @@ This plugin provides a Prometheus `/metrics` endpoint and an extensible way to a - Prometheus compatible authenticated `/metrics` endpoint - Optional default metrics (users, posts, comments, plugins) +- Runtime metrics (HTTP requests, request duration, database queries) - Dedicated plugin settings under 'Settings/Metrics' menu - Extensible by other plugins using `wp_prometheus_collect_metrics` action hook - License management integration @@ -26,11 +27,11 @@ This project is proudly **"vibe-coded"** using Claude.AI - the entire codebase w **Note for AI Assistants:** Clean this section after the specific features are done or new releases are made. Effective changes are tracked in `CHANGELOG.md`. Do not add completed versions here - document them in the Session History section at the end of this file. -### Version 0.1.0 (Planned) +### Version 0.2.0 (Planned) -- Add request/response timing metrics -- Add HTTP status code counters -- Add database query metrics +- WooCommerce integration metrics +- Cron job metrics +- Transient cache metrics ## Technical Stack @@ -224,7 +225,8 @@ wp-prometheus/ │ ├── License/ │ │ └── Manager.php # License management │ ├── Metrics/ -│ │ └── Collector.php # Prometheus metrics collector +│ │ ├── Collector.php # Prometheus metrics collector +│ │ └── RuntimeCollector.php # Runtime metrics collector │ ├── Installer.php # Activation/Deactivation │ ├── Plugin.php # Main plugin class │ └── index.php @@ -280,6 +282,25 @@ add_action( 'wp_prometheus_collect_metrics', function( $collector ) { ## Session History +### 2026-02-02 - Runtime Metrics (v0.1.0) + +- Implemented runtime metrics collection for HTTP requests and database queries +- Created `RuntimeCollector` class that hooks into WordPress request lifecycle +- Added new metrics: + - `wordpress_http_requests_total` - Counter by method, status, endpoint + - `wordpress_http_request_duration_seconds` - Histogram of request durations + - `wordpress_db_queries_total` - Counter by endpoint + - `wordpress_db_query_duration_seconds` - Histogram (requires SAVEQUERIES) +- Updated `Collector` class to expose stored runtime metrics +- Added new settings options in admin for enabling/disabling runtime metrics +- Created translation files (.pot, .po, .mo) for internationalization +- **Key Learning**: With InMemory Prometheus storage, counters/histograms reset per request + - Solution: Store aggregated data in WordPress options, read during metrics collection + - Histograms exposed as gauge metrics following Prometheus naming conventions (`_bucket`, `_sum`, `_count`) +- **Key Learning**: Endpoint normalization is important for cardinality control + - Group requests into categories (admin, ajax, cron, rest-api, frontend, etc.) + - Avoid high-cardinality labels like full URL paths + ### 2026-02-01 - CI/CD Fixes (v0.0.2) - Fixed composer.json dependency configuration for CI compatibility diff --git a/PLAN.md b/PLAN.md index 6ad93df..1a5a796 100644 --- a/PLAN.md +++ b/PLAN.md @@ -91,6 +91,8 @@ wp-prometheus/ The plugin provides the following default metrics (can be toggled in settings): +### Static Metrics + | Metric | Type | Labels | Description | |--------|------|--------|-------------| | wordpress_info | Gauge | version, php_version, multisite | WordPress installation info | @@ -99,6 +101,15 @@ The plugin provides the following default metrics (can be toggled in settings): | wordpress_comments_total | Gauge | status | Total comments by status | | wordpress_plugins_total | Gauge | status | Total plugins (active/inactive) | +### Runtime Metrics + +| Metric | Type | Labels | Description | +| ---------------------------------------- | --------- | ------------------------ | ------------------------------------- | +| wordpress_http_requests_total | Counter | method, status, endpoint | Total HTTP requests | +| wordpress_http_request_duration_seconds | Histogram | method, endpoint | Request duration distribution | +| wordpress_db_queries_total | Counter | endpoint | Total database queries | +| wordpress_db_query_duration_seconds | Histogram | endpoint | Query duration (requires SAVEQUERIES) | + ## Extensibility ### Adding Custom Metrics @@ -150,12 +161,6 @@ https://example.com/metrics/?token=your-auth-token ## Future Enhancements -### Version 0.1.0 - -- Request/Response timing metrics -- HTTP status code counters -- Database query metrics - ### Version 0.2.0 - WooCommerce integration metrics diff --git a/README.md b/README.md index febf916..b01f6e6 100644 --- a/README.md +++ b/README.md @@ -71,6 +71,8 @@ scrape_configs: ## Default Metrics +### Static Metrics + | Metric | Type | Labels | Description | |--------|------|--------|-------------| | wordpress_info | Gauge | version, php_version, multisite | WordPress installation info | @@ -79,6 +81,17 @@ scrape_configs: | wordpress_comments_total | Gauge | status | Total comments by status | | wordpress_plugins_total | Gauge | status | Total plugins (active/inactive) | +### Runtime Metrics (v0.1.0+) + +| Metric | Type | Labels | Description | +|--------|------|--------|-------------| +| wordpress_http_requests_total | Counter | method, status, endpoint | Total HTTP requests | +| wordpress_http_request_duration_seconds | Histogram | method, endpoint | Request duration distribution | +| wordpress_db_queries_total | Counter | endpoint | Total database queries | +| wordpress_db_query_duration_seconds | Histogram | endpoint | Query duration (requires SAVEQUERIES) | + +**Note:** Runtime metrics aggregate data across requests. Enable only the metrics you need to minimize performance impact. + ## Extending with Custom Metrics Add your own metrics using the `wp_prometheus_collect_metrics` action: diff --git a/languages/wp-prometheus-de_CH.mo b/languages/wp-prometheus-de_CH.mo new file mode 100644 index 0000000..4f1dcca Binary files /dev/null and b/languages/wp-prometheus-de_CH.mo differ diff --git a/languages/wp-prometheus-de_CH.po b/languages/wp-prometheus-de_CH.po new file mode 100644 index 0000000..48f9c60 --- /dev/null +++ b/languages/wp-prometheus-de_CH.po @@ -0,0 +1,198 @@ +# German (Switzerland, formal) translation for WP Prometheus. +# Copyright (C) 2026 Marco Graetsch +# This file is distributed under the GPL v2 or later. +msgid "" +msgstr "" +"Project-Id-Version: WP Prometheus 0.1.0\n" +"Report-Msgid-Bugs-To: https://src.bundespruefstelle.ch/magdev/wp-prometheus/issues\n" +"POT-Creation-Date: 2026-02-02T00:00:00+00:00\n" +"PO-Revision-Date: 2026-02-02T00:00:00+00:00\n" +"Last-Translator: Marco Graetsch \n" +"Language-Team: German (Switzerland) \n" +"Language: de_CH\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" + +#: src/Admin/Settings.php:40 +msgid "Metrics Settings" +msgstr "Metriken-Einstellungen" + +#: src/Admin/Settings.php:41 +msgid "Metrics" +msgstr "Metriken" + +#: src/Admin/Settings.php:58 +msgid "License Settings" +msgstr "Lizenz-Einstellungen" + +#: src/Admin/Settings.php:65 +msgid "Authentication" +msgstr "Authentifizierung" + +#: src/Admin/Settings.php:73 +msgid "Default Metrics" +msgstr "Standard-Metriken" + +#: src/Admin/Settings.php:150 +msgid "License settings saved." +msgstr "Lizenz-Einstellungen gespeichert." + +#: src/Admin/Settings.php:195 +msgid "License is active and valid." +msgstr "Lizenz ist aktiv und gueltig." + +#: src/Admin/Settings.php:196 +msgid "License is invalid." +msgstr "Lizenz ist ungueltig." + +#: src/Admin/Settings.php:197 +msgid "License has expired." +msgstr "Lizenz ist abgelaufen." + +#: src/Admin/Settings.php:198 +msgid "License has been revoked." +msgstr "Lizenz wurde widerrufen." + +#: src/Admin/Settings.php:199 +msgid "License is inactive." +msgstr "Lizenz ist inaktiv." + +#: src/Admin/Settings.php:200 +msgid "License has not been validated yet." +msgstr "Lizenz wurde noch nicht validiert." + +#: src/Admin/Settings.php:201 +msgid "License server is not configured." +msgstr "Lizenz-Server ist nicht konfiguriert." + +#. translators: %s: Expiration date +#: src/Admin/Settings.php:214 +msgid "Expires: %s" +msgstr "Laeuft ab: %s" + +#. translators: %s: Time ago +#: src/Admin/Settings.php:225 +msgid "Last checked: %s ago" +msgstr "Zuletzt geprueft: vor %s" + +#: src/Admin/Settings.php:239 +msgid "License Server URL" +msgstr "Lizenz-Server URL" + +#: src/Admin/Settings.php:249 +msgid "License Key" +msgstr "Lizenzschluessel" + +#: src/Admin/Settings.php:259 +msgid "Server Secret" +msgstr "Server-Geheimnis" + +#: src/Admin/Settings.php:264 +msgid "Leave empty to keep existing." +msgstr "Leer lassen, um bestehenden Wert zu behalten." + +#: src/Admin/Settings.php:270 +msgid "Save License Settings" +msgstr "Lizenz-Einstellungen speichern" + +#: src/Admin/Settings.php:272 +msgid "Validate License" +msgstr "Lizenz validieren" + +#: src/Admin/Settings.php:275 +msgid "Activate License" +msgstr "Lizenz aktivieren" + +#: src/Admin/Settings.php:301 +msgid "Configure authentication for the /metrics endpoint." +msgstr "Authentifizierung fuer den /metrics-Endpunkt konfigurieren." + +#: src/Admin/Settings.php:310 +msgid "Select which default metrics to expose." +msgstr "Waehlen Sie, welche Standard-Metriken bereitgestellt werden sollen." + +#: src/Admin/Settings.php:324 +msgid "Regenerate" +msgstr "Neu generieren" + +#: src/Admin/Settings.php:327 +msgid "Use this token to authenticate Prometheus scrape requests." +msgstr "Verwenden Sie diesen Token zur Authentifizierung von Prometheus-Abfragen." + +#: src/Admin/Settings.php:340 +msgid "WordPress Info (version, PHP version, multisite)" +msgstr "WordPress-Info (Version, PHP-Version, Multisite)" + +#: src/Admin/Settings.php:341 +msgid "Total Users by Role" +msgstr "Benutzer nach Rolle" + +#: src/Admin/Settings.php:342 +msgid "Total Posts by Type and Status" +msgstr "Beitraege nach Typ und Status" + +#: src/Admin/Settings.php:343 +msgid "Total Comments by Status" +msgstr "Kommentare nach Status" + +#: src/Admin/Settings.php:344 +msgid "Total Plugins (active/inactive)" +msgstr "Plugins (aktiv/inaktiv)" + +#: src/Admin/Settings.php:345 +msgid "HTTP Requests Total (by method, status, endpoint)" +msgstr "HTTP-Anfragen gesamt (nach Methode, Status, Endpunkt)" + +#: src/Admin/Settings.php:346 +msgid "HTTP Request Duration (histogram)" +msgstr "HTTP-Anfragedauer (Histogramm)" + +#: src/Admin/Settings.php:347 +msgid "Database Queries Total (by endpoint)" +msgstr "Datenbank-Abfragen gesamt (nach Endpunkt)" + +#: src/Admin/Settings.php:369 +msgid "Prometheus Configuration" +msgstr "Prometheus-Konfiguration" + +#: src/Admin/Settings.php:370 +msgid "Add the following to your prometheus.yml:" +msgstr "Fuegen Sie Folgendes zu Ihrer prometheus.yml hinzu:" + +#. translators: %s: Endpoint URL +#: src/Admin/Settings.php:385 +msgid "Metrics endpoint: %s" +msgstr "Metriken-Endpunkt: %s" + +#: src/Admin/Settings.php:93 +msgid "Auth Token" +msgstr "Auth-Token" + +#: src/Admin/Settings.php:101 +msgid "Enabled Metrics" +msgstr "Aktivierte Metriken" + +#: src/Plugin.php:120 +msgid "Settings" +msgstr "Einstellungen" + +#. translators: 1: Required PHP version, 2: Current PHP version +#: wp-prometheus.php:112 +msgid "WP Prometheus requires PHP version %1$s or higher. You are running PHP %2$s." +msgstr "WP Prometheus erfordert PHP-Version %1$s oder hoeher. Sie verwenden PHP %2$s." + +#. translators: 1: Required WordPress version, 2: Current WordPress version +#: wp-prometheus.php:127 +msgid "WP Prometheus requires WordPress version %1$s or higher. You are running WordPress %2$s." +msgstr "WP Prometheus erfordert WordPress-Version %1$s oder hoeher. Sie verwenden WordPress %2$s." + +#: wp-prometheus.php:140 +msgid "WP Prometheus requires Composer dependencies to be installed. Please run \"composer install\" in the plugin directory." +msgstr "WP Prometheus erfordert installierte Composer-Abhaengigkeiten. Bitte fuehren Sie \"composer install\" im Plugin-Verzeichnis aus." + +#. translators: %s: Required PHP version +#: wp-prometheus.php:156 +msgid "WP Prometheus requires PHP version %s or higher." +msgstr "WP Prometheus erfordert PHP-Version %s oder hoeher." diff --git a/languages/wp-prometheus.pot b/languages/wp-prometheus.pot new file mode 100644 index 0000000..5028cbd --- /dev/null +++ b/languages/wp-prometheus.pot @@ -0,0 +1,195 @@ +# Copyright (C) 2026 Marco Graetsch +# This file is distributed under the GPL v2 or later. +msgid "" +msgstr "" +"Project-Id-Version: WP Prometheus 0.1.0\n" +"Report-Msgid-Bugs-To: https://src.bundespruefstelle.ch/magdev/wp-prometheus/issues\n" +"POT-Creation-Date: 2026-02-02T00:00:00+00:00\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"PO-Revision-Date: 2026-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \n" + +#: src/Admin/Settings.php:40 +msgid "Metrics Settings" +msgstr "" + +#: src/Admin/Settings.php:41 +msgid "Metrics" +msgstr "" + +#: src/Admin/Settings.php:58 +msgid "License Settings" +msgstr "" + +#: src/Admin/Settings.php:65 +msgid "Authentication" +msgstr "" + +#: src/Admin/Settings.php:73 +msgid "Default Metrics" +msgstr "" + +#: src/Admin/Settings.php:150 +msgid "License settings saved." +msgstr "" + +#: src/Admin/Settings.php:195 +msgid "License is active and valid." +msgstr "" + +#: src/Admin/Settings.php:196 +msgid "License is invalid." +msgstr "" + +#: src/Admin/Settings.php:197 +msgid "License has expired." +msgstr "" + +#: src/Admin/Settings.php:198 +msgid "License has been revoked." +msgstr "" + +#: src/Admin/Settings.php:199 +msgid "License is inactive." +msgstr "" + +#: src/Admin/Settings.php:200 +msgid "License has not been validated yet." +msgstr "" + +#: src/Admin/Settings.php:201 +msgid "License server is not configured." +msgstr "" + +#. translators: %s: Expiration date +#: src/Admin/Settings.php:214 +msgid "Expires: %s" +msgstr "" + +#. translators: %s: Time ago +#: src/Admin/Settings.php:225 +msgid "Last checked: %s ago" +msgstr "" + +#: src/Admin/Settings.php:239 +msgid "License Server URL" +msgstr "" + +#: src/Admin/Settings.php:249 +msgid "License Key" +msgstr "" + +#: src/Admin/Settings.php:259 +msgid "Server Secret" +msgstr "" + +#: src/Admin/Settings.php:264 +msgid "Leave empty to keep existing." +msgstr "" + +#: src/Admin/Settings.php:270 +msgid "Save License Settings" +msgstr "" + +#: src/Admin/Settings.php:272 +msgid "Validate License" +msgstr "" + +#: src/Admin/Settings.php:275 +msgid "Activate License" +msgstr "" + +#: src/Admin/Settings.php:301 +msgid "Configure authentication for the /metrics endpoint." +msgstr "" + +#: src/Admin/Settings.php:310 +msgid "Select which default metrics to expose." +msgstr "" + +#: src/Admin/Settings.php:324 +msgid "Regenerate" +msgstr "" + +#: src/Admin/Settings.php:327 +msgid "Use this token to authenticate Prometheus scrape requests." +msgstr "" + +#: src/Admin/Settings.php:340 +msgid "WordPress Info (version, PHP version, multisite)" +msgstr "" + +#: src/Admin/Settings.php:341 +msgid "Total Users by Role" +msgstr "" + +#: src/Admin/Settings.php:342 +msgid "Total Posts by Type and Status" +msgstr "" + +#: src/Admin/Settings.php:343 +msgid "Total Comments by Status" +msgstr "" + +#: src/Admin/Settings.php:344 +msgid "Total Plugins (active/inactive)" +msgstr "" + +#: src/Admin/Settings.php:345 +msgid "HTTP Requests Total (by method, status, endpoint)" +msgstr "" + +#: src/Admin/Settings.php:346 +msgid "HTTP Request Duration (histogram)" +msgstr "" + +#: src/Admin/Settings.php:347 +msgid "Database Queries Total (by endpoint)" +msgstr "" + +#: src/Admin/Settings.php:369 +msgid "Prometheus Configuration" +msgstr "" + +#: src/Admin/Settings.php:370 +msgid "Add the following to your prometheus.yml:" +msgstr "" + +#. translators: %s: Endpoint URL +#: src/Admin/Settings.php:385 +msgid "Metrics endpoint: %s" +msgstr "" + +#: src/Admin/Settings.php:93 +msgid "Auth Token" +msgstr "" + +#: src/Admin/Settings.php:101 +msgid "Enabled Metrics" +msgstr "" + +#: src/Plugin.php:120 +msgid "Settings" +msgstr "" + +#. translators: 1: Required PHP version, 2: Current PHP version +#: wp-prometheus.php:112 +msgid "WP Prometheus requires PHP version %1$s or higher. You are running PHP %2$s." +msgstr "" + +#. translators: 1: Required WordPress version, 2: Current WordPress version +#: wp-prometheus.php:127 +msgid "WP Prometheus requires WordPress version %1$s or higher. You are running WordPress %2$s." +msgstr "" + +#: wp-prometheus.php:140 +msgid "WP Prometheus requires Composer dependencies to be installed. Please run \"composer install\" in the plugin directory." +msgstr "" + +#. translators: %s: Required PHP version +#: wp-prometheus.php:156 +msgid "WP Prometheus requires PHP version %s or higher." +msgstr "" diff --git a/src/Admin/Settings.php b/src/Admin/Settings.php index 8b655ae..b0dc424 100644 --- a/src/Admin/Settings.php +++ b/src/Admin/Settings.php @@ -337,11 +337,14 @@ class Settings { public function render_enabled_metrics_field(): void { $enabled = get_option( 'wp_prometheus_enabled_metrics', array() ); $metrics = array( - 'wordpress_info' => __( 'WordPress Info (version, PHP version, multisite)', 'wp-prometheus' ), - 'wordpress_users_total' => __( 'Total Users by Role', 'wp-prometheus' ), - 'wordpress_posts_total' => __( 'Total Posts by Type and Status', 'wp-prometheus' ), - 'wordpress_comments_total' => __( 'Total Comments by Status', 'wp-prometheus' ), - 'wordpress_plugins_total' => __( 'Total Plugins (active/inactive)', 'wp-prometheus' ), + 'wordpress_info' => __( 'WordPress Info (version, PHP version, multisite)', 'wp-prometheus' ), + 'wordpress_users_total' => __( 'Total Users by Role', 'wp-prometheus' ), + 'wordpress_posts_total' => __( 'Total Posts by Type and Status', 'wp-prometheus' ), + 'wordpress_comments_total' => __( 'Total Comments by Status', 'wp-prometheus' ), + 'wordpress_plugins_total' => __( 'Total Plugins (active/inactive)', 'wp-prometheus' ), + 'wordpress_http_requests_total' => __( 'HTTP Requests Total (by method, status, endpoint)', 'wp-prometheus' ), + 'wordpress_http_request_duration_seconds' => __( 'HTTP Request Duration (histogram)', 'wp-prometheus' ), + 'wordpress_db_queries_total' => __( 'Database Queries Total (by endpoint)', 'wp-prometheus' ), ); foreach ( $metrics as $key => $label ) { diff --git a/src/Installer.php b/src/Installer.php index 661bf0e..e0cb442 100644 --- a/src/Installer.php +++ b/src/Installer.php @@ -63,6 +63,7 @@ final class Installer { 'wp_prometheus_auth_token', 'wp_prometheus_enable_default_metrics', 'wp_prometheus_enabled_metrics', + 'wp_prometheus_runtime_metrics', ); foreach ( $options as $option ) { diff --git a/src/Metrics/Collector.php b/src/Metrics/Collector.php index 5cb8a71..628ce95 100644 --- a/src/Metrics/Collector.php +++ b/src/Metrics/Collector.php @@ -95,6 +95,9 @@ class Collector { $this->collect_plugins_total(); } + // Collect runtime metrics (HTTP requests, DB queries). + $this->collect_runtime_metrics( $enabled_metrics ); + /** * Fires after default metrics are collected. * @@ -233,6 +236,153 @@ class Collector { $gauge->set( count( $all_plugins ) - count( $active_plugins ), array( 'inactive' ) ); } + /** + * Collect runtime metrics from stored data. + * + * @param array $enabled_metrics List of enabled metrics. + * @return void + */ + private function collect_runtime_metrics( array $enabled_metrics ): void { + $runtime_collector = RuntimeCollector::get_instance(); + $stored_metrics = $runtime_collector->get_stored_metrics(); + + // HTTP requests total counter. + if ( in_array( 'wordpress_http_requests_total', $enabled_metrics, true ) && ! empty( $stored_metrics['counters'] ) ) { + foreach ( $stored_metrics['counters'] as $counter_data ) { + if ( 'http_requests_total' !== $counter_data['name'] ) { + continue; + } + + $counter = $this->registry->getOrRegisterCounter( + $this->namespace, + 'http_requests_total', + 'Total number of HTTP requests', + array( 'method', 'status', 'endpoint' ) + ); + + $counter->incBy( + (int) $counter_data['value'], + array( + $counter_data['labels']['method'] ?? 'GET', + $counter_data['labels']['status'] ?? '200', + $counter_data['labels']['endpoint'] ?? 'unknown', + ) + ); + } + } + + // HTTP request duration histogram. + if ( in_array( 'wordpress_http_request_duration_seconds', $enabled_metrics, true ) && ! empty( $stored_metrics['histograms'] ) ) { + foreach ( $stored_metrics['histograms'] as $histogram_data ) { + if ( 'http_request_duration_seconds' !== $histogram_data['name'] ) { + continue; + } + + // For histograms, we expose as a gauge with pre-aggregated bucket counts. + // This is a workaround since we can't directly populate histogram buckets. + $this->expose_histogram_as_gauges( + 'http_request_duration_seconds', + 'HTTP request duration in seconds', + $histogram_data, + array( 'method', 'endpoint' ) + ); + } + } + + // Database queries total counter. + if ( in_array( 'wordpress_db_queries_total', $enabled_metrics, true ) && ! empty( $stored_metrics['counters'] ) ) { + foreach ( $stored_metrics['counters'] as $counter_data ) { + if ( 'db_queries_total' !== $counter_data['name'] ) { + continue; + } + + $counter = $this->registry->getOrRegisterCounter( + $this->namespace, + 'db_queries_total', + 'Total number of database queries', + array( 'endpoint' ) + ); + + $counter->incBy( + (int) $counter_data['value'], + array( + $counter_data['labels']['endpoint'] ?? 'unknown', + ) + ); + } + } + + // Database query duration histogram (if SAVEQUERIES is enabled). + if ( in_array( 'wordpress_db_queries_total', $enabled_metrics, true ) && ! empty( $stored_metrics['histograms'] ) ) { + foreach ( $stored_metrics['histograms'] as $histogram_data ) { + if ( 'db_query_duration_seconds' !== $histogram_data['name'] ) { + continue; + } + + $this->expose_histogram_as_gauges( + 'db_query_duration_seconds', + 'Database query duration in seconds', + $histogram_data, + array( 'endpoint' ) + ); + } + } + } + + /** + * Expose pre-aggregated histogram data as gauge metrics. + * + * Since we store histogram data externally, we expose it using gauges + * that follow Prometheus histogram naming conventions. + * + * @param string $name Metric name. + * @param string $help Metric description. + * @param array $histogram_data Stored histogram data. + * @param array $label_names Label names. + * @return void + */ + private function expose_histogram_as_gauges( string $name, string $help, array $histogram_data, array $label_names ): void { + $label_values = array(); + foreach ( $label_names as $label_name ) { + $label_values[] = $histogram_data['labels'][ $label_name ] ?? 'unknown'; + } + + // Expose bucket counts. + $bucket_gauge = $this->registry->getOrRegisterGauge( + $this->namespace, + $name . '_bucket', + $help . ' (bucket)', + array_merge( $label_names, array( 'le' ) ) + ); + + $cumulative_count = 0; + foreach ( $histogram_data['buckets'] as $le => $count ) { + $cumulative_count += $count; + $bucket_gauge->set( + $cumulative_count, + array_merge( $label_values, array( $le ) ) + ); + } + + // Expose sum. + $sum_gauge = $this->registry->getOrRegisterGauge( + $this->namespace, + $name . '_sum', + $help . ' (sum)', + $label_names + ); + $sum_gauge->set( $histogram_data['sum'], $label_values ); + + // Expose count. + $count_gauge = $this->registry->getOrRegisterGauge( + $this->namespace, + $name . '_count', + $help . ' (count)', + $label_names + ); + $count_gauge->set( $histogram_data['count'], $label_values ); + } + /** * Register a custom gauge metric. * diff --git a/src/Metrics/RuntimeCollector.php b/src/Metrics/RuntimeCollector.php new file mode 100644 index 0000000..12088b8 --- /dev/null +++ b/src/Metrics/RuntimeCollector.php @@ -0,0 +1,371 @@ +request_start_time = microtime( true ); + $this->init_hooks(); + } + + /** + * Initialize WordPress hooks. + * + * @return void + */ + private function init_hooks(): void { + // Record metrics at the end of the request. + add_action( 'shutdown', array( $this, 'record_request_metrics' ), 9999 ); + } + + /** + * Record request metrics at shutdown. + * + * @return void + */ + public function record_request_metrics(): void { + // Skip metrics endpoint requests to avoid self-referential metrics. + if ( $this->is_metrics_request() ) { + return; + } + + // Skip AJAX requests for license validation etc. from this plugin. + if ( $this->is_plugin_ajax_request() ) { + return; + } + + $enabled_metrics = get_option( 'wp_prometheus_enabled_metrics', array() ); + $metrics = $this->get_stored_metrics(); + $duration = microtime( true ) - $this->request_start_time; + $status_code = http_response_code() ?: 200; + $method = isset( $_SERVER['REQUEST_METHOD'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REQUEST_METHOD'] ) ) : 'GET'; + $endpoint = $this->get_normalized_endpoint(); + + // Record HTTP request count and duration. + if ( in_array( 'wordpress_http_requests_total', $enabled_metrics, true ) ) { + $this->increment_counter( + $metrics, + 'http_requests_total', + array( + 'method' => $method, + 'status' => (string) $status_code, + 'endpoint' => $endpoint, + ) + ); + } + + // Record request duration histogram. + if ( in_array( 'wordpress_http_request_duration_seconds', $enabled_metrics, true ) ) { + $this->observe_histogram( + $metrics, + 'http_request_duration_seconds', + $duration, + array( + 'method' => $method, + 'endpoint' => $endpoint, + ), + self::DURATION_BUCKETS + ); + } + + // Record database query metrics. + if ( in_array( 'wordpress_db_queries_total', $enabled_metrics, true ) ) { + global $wpdb; + $query_count = $wpdb->num_queries; + + $this->increment_counter( + $metrics, + 'db_queries_total', + array( 'endpoint' => $endpoint ), + $query_count + ); + + // Track query time if SAVEQUERIES is enabled. + if ( defined( 'SAVEQUERIES' ) && SAVEQUERIES && ! empty( $wpdb->queries ) ) { + $total_query_time = 0; + foreach ( $wpdb->queries as $query ) { + $total_query_time += $query[1]; // Query time is the second element. + } + + $this->observe_histogram( + $metrics, + 'db_query_duration_seconds', + $total_query_time, + array( 'endpoint' => $endpoint ), + self::DURATION_BUCKETS + ); + } + } + + $this->save_stored_metrics( $metrics ); + } + + /** + * Check if current request is for the metrics endpoint. + * + * @return bool + */ + private function is_metrics_request(): bool { + $request_uri = isset( $_SERVER['REQUEST_URI'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REQUEST_URI'] ) ) : ''; + return strpos( $request_uri, '/metrics' ) !== false; + } + + /** + * Check if current request is a plugin AJAX request. + * + * @return bool + */ + private function is_plugin_ajax_request(): bool { + if ( ! wp_doing_ajax() ) { + return false; + } + + $action = isset( $_REQUEST['action'] ) ? sanitize_text_field( wp_unslash( $_REQUEST['action'] ) ) : ''; + return strpos( $action, 'wp_prometheus_' ) === 0; + } + + /** + * Get normalized endpoint for labeling. + * + * @return string + */ + private function get_normalized_endpoint(): string { + $request_uri = isset( $_SERVER['REQUEST_URI'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REQUEST_URI'] ) ) : '/'; + + // Remove query string. + $path = strtok( $request_uri, '?' ); + + // Normalize common patterns. + if ( is_admin() ) { + return 'admin'; + } + + if ( wp_doing_ajax() ) { + return 'ajax'; + } + + if ( wp_doing_cron() ) { + return 'cron'; + } + + if ( defined( 'REST_REQUEST' ) && REST_REQUEST ) { + return 'rest-api'; + } + + if ( defined( 'XMLRPC_REQUEST' ) && XMLRPC_REQUEST ) { + return 'xmlrpc'; + } + + // Check for common WordPress patterns. + if ( preg_match( '#^/wp-json/#', $path ) ) { + return 'rest-api'; + } + + if ( preg_match( '#^/wp-login\.php#', $path ) ) { + return 'login'; + } + + if ( preg_match( '#^/wp-cron\.php#', $path ) ) { + return 'cron'; + } + + if ( preg_match( '#^/feed/#', $path ) || preg_match( '#/feed/?$#', $path ) ) { + return 'feed'; + } + + // Generic frontend. + return 'frontend'; + } + + /** + * Get stored metrics from database. + * + * @return array + */ + public function get_stored_metrics(): array { + $metrics = get_option( self::OPTION_NAME, array() ); + + if ( ! is_array( $metrics ) ) { + return array( + 'counters' => array(), + 'histograms' => array(), + 'last_reset' => time(), + ); + } + + return $metrics; + } + + /** + * Save stored metrics to database. + * + * @param array $metrics Metrics data. + * @return void + */ + private function save_stored_metrics( array $metrics ): void { + update_option( self::OPTION_NAME, $metrics, false ); + } + + /** + * Increment a counter metric. + * + * @param array $metrics Reference to metrics array. + * @param string $name Counter name. + * @param array $labels Label values. + * @param int $increment Amount to increment (default 1). + * @return void + */ + private function increment_counter( array &$metrics, string $name, array $labels, int $increment = 1 ): void { + if ( ! isset( $metrics['counters'] ) ) { + $metrics['counters'] = array(); + } + + $key = $this->make_label_key( $name, $labels ); + + if ( ! isset( $metrics['counters'][ $key ] ) ) { + $metrics['counters'][ $key ] = array( + 'name' => $name, + 'labels' => $labels, + 'value' => 0, + ); + } + + $metrics['counters'][ $key ]['value'] += $increment; + } + + /** + * Observe a value in a histogram metric. + * + * @param array $metrics Reference to metrics array. + * @param string $name Histogram name. + * @param float $value Observed value. + * @param array $labels Label values. + * @param array $buckets Bucket boundaries. + * @return void + */ + private function observe_histogram( array &$metrics, string $name, float $value, array $labels, array $buckets ): void { + if ( ! isset( $metrics['histograms'] ) ) { + $metrics['histograms'] = array(); + } + + $key = $this->make_label_key( $name, $labels ); + + if ( ! isset( $metrics['histograms'][ $key ] ) ) { + $bucket_counts = array(); + foreach ( $buckets as $bucket ) { + $bucket_counts[ (string) $bucket ] = 0; + } + $bucket_counts['+Inf'] = 0; + + $metrics['histograms'][ $key ] = array( + 'name' => $name, + 'labels' => $labels, + 'buckets' => $bucket_counts, + 'sum' => 0.0, + 'count' => 0, + ); + } + + // Increment bucket counts. + foreach ( $buckets as $bucket ) { + if ( $value <= $bucket ) { + $metrics['histograms'][ $key ]['buckets'][ (string) $bucket ]++; + } + } + $metrics['histograms'][ $key ]['buckets']['+Inf']++; + + // Update sum and count. + $metrics['histograms'][ $key ]['sum'] += $value; + $metrics['histograms'][ $key ]['count']++; + } + + /** + * Create a unique key from name and labels. + * + * @param string $name Metric name. + * @param array $labels Label values. + * @return string + */ + private function make_label_key( string $name, array $labels ): string { + ksort( $labels ); + return $name . ':' . wp_json_encode( $labels ); + } + + /** + * Reset stored metrics. + * + * @return void + */ + public static function reset_metrics(): void { + delete_option( self::OPTION_NAME ); + } + + /** + * Get histogram buckets. + * + * @return array + */ + public static function get_duration_buckets(): array { + return self::DURATION_BUCKETS; + } +} diff --git a/src/Plugin.php b/src/Plugin.php index 740ce95..ec5c2ec 100644 --- a/src/Plugin.php +++ b/src/Plugin.php @@ -11,6 +11,7 @@ use Magdev\WpPrometheus\Admin\Settings; use Magdev\WpPrometheus\Endpoint\MetricsEndpoint; use Magdev\WpPrometheus\License\Manager as LicenseManager; use Magdev\WpPrometheus\Metrics\Collector; +use Magdev\WpPrometheus\Metrics\RuntimeCollector; // Prevent direct file access. if ( ! defined( 'ABSPATH' ) ) { @@ -90,6 +91,20 @@ final class Plugin { new Settings(); } + // Initialize runtime collector for request metrics (always runs to collect data). + // Only collect if at least one runtime metric is enabled. + $enabled_metrics = get_option( 'wp_prometheus_enabled_metrics', array() ); + $runtime_metrics = array( + 'wordpress_http_requests_total', + 'wordpress_http_request_duration_seconds', + 'wordpress_db_queries_total', + ); + $has_runtime_metrics = ! empty( array_intersect( $runtime_metrics, $enabled_metrics ) ); + + if ( $has_runtime_metrics && LicenseManager::is_license_valid() ) { + RuntimeCollector::get_instance(); + } + // Initialize metrics endpoint (only if licensed). if ( LicenseManager::is_license_valid() ) { $this->collector = new Collector(); diff --git a/wp-prometheus.php b/wp-prometheus.php index a5b7d89..fb617aa 100644 --- a/wp-prometheus.php +++ b/wp-prometheus.php @@ -3,7 +3,7 @@ * Plugin Name: WP Prometheus * Plugin URI: https://src.bundespruefstelle.ch/magdev/wp-prometheus * Description: Prometheus metrics endpoint for WordPress with extensible hooks for custom metrics. - * Version: 0.0.2 + * Version: 0.1.0 * Requires at least: 6.4 * Requires PHP: 8.3 * Author: Marco Graetsch @@ -26,7 +26,7 @@ if ( ! defined( 'ABSPATH' ) ) { * * @var string */ -define( 'WP_PROMETHEUS_VERSION', '0.0.2' ); +define( 'WP_PROMETHEUS_VERSION', '0.1.0' ); /** * Plugin file path.