5 Commits
v0.4.3 ... dev

Author SHA1 Message Date
63660202c4 docs: Clarify translation compilation is handled by CI/CD
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-03 19:33:02 +01:00
3b71a0f7c9 docs: Add database query timing documentation and dashboard panel (v0.4.7)
All checks were successful
Create Release Package / build-release (push) Successful in 56s
- Add Query Duration Distribution panel to Grafana Runtime dashboard
- Add wordpress_db_query_duration_seconds to Help tab metrics reference
- Add SAVEQUERIES documentation section to README
- Update translation files with new strings

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-03 19:27:57 +01:00
5aaa73ec24 feat: Add dashboard extension hook for third-party plugins (v0.4.6)
All checks were successful
Create Release Package / build-release (push) Successful in 1m5s
Add wp_prometheus_register_dashboards action hook allowing third-party
plugins to register their own Grafana dashboard templates.

- DashboardProvider: registration system with file/JSON support
- Security: path traversal protection, JSON validation
- Admin UI: "Extension" badge and plugin attribution
- Isolated mode support for dashboard registration hook

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-03 11:16:18 +01:00
e5f2edbafa fix: Separate settings groups to prevent cross-tab overwrites (v0.4.5)
All checks were successful
Create Release Package / build-release (push) Successful in 1m2s
Split Metrics sub-tab settings into separate WordPress option groups:
- wp_prometheus_endpoint_settings for auth token
- wp_prometheus_selection_settings for enabled metrics
- wp_prometheus_advanced_settings for isolated mode

This fixes the bug where saving from one sub-tab would clear settings
from other sub-tabs due to all settings sharing a single option group.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-02 23:12:41 +01:00
7f0b6ec8a6 docs: Add version mismatch learning to CLAUDE.md
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-02 21:50:56 +01:00
12 changed files with 999 additions and 106 deletions

View File

@@ -5,6 +5,76 @@ 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/), 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). and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [0.4.7] - 2026-02-03
### Added
- Database query duration distribution panel in Grafana Runtime dashboard
- `wordpress_db_query_duration_seconds` metric now listed in Help tab
- Documentation for enabling `SAVEQUERIES` constant for query timing
### Changed
- Updated README with instructions for enabling database query timing
- Grafana Runtime dashboard now includes bucket distribution chart for DB queries
## [0.4.6] - 2026-02-03
### Added
- Dashboard extension hook `wp_prometheus_register_dashboards` for third-party plugins
- Third-party plugins can now register their own Grafana dashboard templates
- Support for file-based and inline JSON dashboard registration
- "Extension" badge for third-party dashboards in admin UI
- Plugin attribution display for third-party dashboards
- Security: Path traversal protection for registered dashboard files
- Isolated mode support for dashboard registration hook
### Changed
- DashboardProvider now supports both built-in and third-party registered dashboards
- Dashboard cards show source (built-in vs extension) with visual distinction
## [0.4.5] - 2026-02-02
### Fixed
- Settings now persist correctly across Metrics sub-tabs
- Auth token no longer gets cleared when saving from Selection sub-tab
- Enabled metrics no longer get cleared when saving from Endpoint sub-tab
- Isolated mode setting no longer gets cleared when saving from other sub-tabs
### Changed
- Split Metrics settings into separate WordPress option groups per sub-tab
- Each sub-tab now uses its own settings group to prevent cross-tab overwrites
## [0.4.4] - 2026-02-02
### Added
- Safe mode for metrics collection (default):
- Removes problematic content filters early
- Allows third-party plugins to register `wp_prometheus_collect_metrics` hooks
- Wraps custom hooks in output buffering and try-catch for protection
- Isolated mode option for maximum compatibility:
- Outputs metrics before other plugins fully load
- Use only if Safe mode causes issues
- `WP_PROMETHEUS_ISOLATED_MODE` environment variable support
- Mode comparison table in admin settings
### Changed
- Replaced "early mode" with two clear modes: Safe (default) and Isolated
- Custom metrics hooks now fire by default with protection against recursion
- Filter removal now also includes `the_content_feed` and `comment_text`
- Updated admin UI with clearer explanations of each mode
### Fixed
- Third-party plugins can now add custom metrics without memory issues
- Twig-based plugins (like wp-fedistream) no longer cause recursion
## [0.4.3] - 2026-02-02 ## [0.4.3] - 2026-02-02
### Added ### Added

View File

@@ -23,6 +23,7 @@ This plugin provides a Prometheus `/metrics` endpoint and an extensible way to a
- Grafana dashboard templates for easy visualization - Grafana dashboard templates for easy visualization
- Dedicated plugin settings under 'Settings/Metrics' menu - Dedicated plugin settings under 'Settings/Metrics' menu
- Extensible by other plugins using `wp_prometheus_collect_metrics` action hook - Extensible by other plugins using `wp_prometheus_collect_metrics` action hook
- Dashboard extension hook `wp_prometheus_register_dashboards` for third-party Grafana dashboards
- License management integration - License management integration
### Key Fact: 100% AI-Generated ### Key Fact: 100% AI-Generated
@@ -33,7 +34,7 @@ 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. **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.
*No planned features at this time.* *No pending roadmap items.*
## Technical Stack ## Technical Stack
@@ -78,11 +79,7 @@ Text domain: `wp-prometheus`
- `en_US` - English (United States) [base language - .pot template] - `en_US` - English (United States) [base language - .pot template]
- `de_CH` - German (Switzerland, formal) - `de_CH` - German (Switzerland, formal)
To compile translations to .mo files for production: Translation compilation (.po → .mo) is handled automatically by CI/CD pipeline during release builds. No local compilation needed.
```bash
for po in languages/*.po; do msgfmt -o "${po%.po}.mo" "$po"; done
```
### Create releases ### Create releases
@@ -291,6 +288,88 @@ add_action( 'wp_prometheus_collect_metrics', function( $collector ) {
## Session History ## Session History
### 2026-02-03 - Database Query Timing Documentation (v0.4.7)
- Added database query duration distribution panel to Grafana Runtime dashboard
- Added `wordpress_db_query_duration_seconds` metric to Help tab metrics reference
- Added documentation in README explaining how to enable `SAVEQUERIES` for query timing
- Updated translation files (.pot and .po) with new strings
- **Key Learning**: WordPress `SAVEQUERIES` constant
- Enables `$wpdb->queries` array with query strings, timing, and call stacks
- Required for `wordpress_db_query_duration_seconds` histogram metric
- Has performance overhead - recommended for development, use cautiously in production
- Without it, only query counts are available (not timing data)
### 2026-02-03 - Dashboard Extension Hook (v0.4.6)
- Added `wp_prometheus_register_dashboards` action hook for third-party plugins
- Third-party plugins can now register their own Grafana dashboard templates
- Implementation in `DashboardProvider.php`:
- `register_dashboard(slug, args)` method for registrations
- Supports file-based dashboards (absolute path to JSON) or inline JSON content
- Security: Path traversal protection (files must be under `WP_CONTENT_DIR`)
- `fire_registration_hook()` with output buffering and exception handling
- Respects isolated mode setting (skips third-party hooks when enabled)
- `is_third_party()` and `get_plugin_name()` helper methods
- Updated admin UI in Settings.php:
- "Extension" badge displayed on third-party dashboard cards
- Plugin attribution shown below third-party dashboards
- Visual distinction with blue border for third-party cards
- **Key Learning**: Extension hook design pattern
- Fire hook lazily on first `get_available()` call, not in constructor
- Use `$hook_fired` flag to prevent double-firing
- Wrap hook execution in try-catch to isolate failures
- Validate registrations thoroughly before accepting them
- **Key Learning**: Security for file-based registrations
- Require absolute paths (`path_is_absolute()`)
- Validate files exist and are readable
- Use `realpath()` to resolve symlinks and prevent traversal
- Restrict to `WP_CONTENT_DIR` (not just plugin directories)
### 2026-02-02 - Settings Persistence Fix (v0.4.5)
- Fixed critical bug where settings would get cleared when saving from different Metrics sub-tabs
- Root cause: All settings were registered under single `wp_prometheus_metrics_settings` group
- When saving from "Endpoint" sub-tab, only auth token was in POST data
- WordPress Settings API would process all registered settings in the group
- Missing fields (enabled_metrics, isolated_mode) would receive null/undefined
- Sanitize callbacks returned empty values, overwriting existing settings
- Solution: Split into separate settings groups per sub-tab:
- `wp_prometheus_endpoint_settings` for auth token
- `wp_prometheus_selection_settings` for enabled metrics
- `wp_prometheus_advanced_settings` for isolated mode
- **Key Learning**: WordPress Settings API and multiple forms
- When multiple forms share the same settings group, saving one form can clear settings from another
- Each form with `settings_fields()` should use a unique option group
- `register_setting()` group name must match `settings_fields()` group name
### 2026-02-02 - Safe Mode & Custom Hooks Fix (v0.4.4)
- Redesigned metrics collection to support both plugin compatibility AND custom metrics:
- **Safe Mode (default)**: Removes content filters early but lets WordPress load normally
- **Isolated Mode**: Legacy early mode that skips custom hooks entirely
- Implementation:
- `WP_PROMETHEUS_METRICS_REQUEST` constant set for any /metrics request
- Content filters removed via `plugins_loaded` hook at priority 0
- Collector fires `wp_prometheus_collect_metrics` with protection (output buffering, try-catch)
- `wp_prometheus_isolated_mode` option replaces `wp_prometheus_disable_early_mode`
- `WP_PROMETHEUS_ISOLATED_MODE` environment variable for containerized deployments
- Collector now wraps custom hooks in `fire_custom_metrics_hook()` method:
- Removes content filters again before hook (in case re-added)
- Uses output buffering to discard accidental output
- Catches exceptions to prevent breaking metrics output
- Logs errors when WP_DEBUG is enabled
- Updated admin UI with mode comparison table
- **Key Learning**: Hybrid approach for plugin compatibility
- The memory issue comes from content filter recursion, not just plugin loading
- Removing filters early (before any plugin can trigger them) prevents recursion
- Plugins still load and can register their `wp_prometheus_collect_metrics` hooks
- Hooks fire after filters are removed, in a protected context
- **Key Learning**: Defense in depth for custom hooks
- Remove filters again right before hook fires (plugins may re-add them)
- Output buffering catches any echo/print from misbehaving plugins
- Try-catch prevents one broken plugin from breaking metrics entirely
### 2026-02-02 - Sub-tabs & Early Mode Fix (v0.4.3) ### 2026-02-02 - Sub-tabs & Early Mode Fix (v0.4.3)
- Split Metrics tab into sub-tabs for better organization: - Split Metrics tab into sub-tabs for better organization:
@@ -304,6 +383,10 @@ add_action( 'wp_prometheus_collect_metrics', function( $collector ) {
- Settings must be inside `<form action="options.php">` with `settings_fields()` call - Settings must be inside `<form action="options.php">` with `settings_fields()` call
- Each sub-tab needs its own form wrapper for proper saving - Each sub-tab needs its own form wrapper for proper saving
- Sub-tabs use URL query parameter (`subtab`) within the main tab - Sub-tabs use URL query parameter (`subtab`) within the main tab
- **Key Learning**: WordPress plugin versioning requires TWO updates
- Plugin header comment `Version: x.x.x` (line ~6) - used by WordPress admin
- PHP constant `WP_PROMETHEUS_VERSION` (line ~133) - used internally
- CI/CD checks both must match the git tag, causing release failures if mismatched
### 2026-02-02 - Early Mode Toggle (v0.4.2) ### 2026-02-02 - Early Mode Toggle (v0.4.2)

View File

@@ -6,7 +6,13 @@ A WordPress plugin that provides a Prometheus-compatible `/metrics` endpoint wit
- Prometheus-compatible authenticated `/metrics` endpoint - Prometheus-compatible authenticated `/metrics` endpoint
- Default WordPress metrics (users, posts, comments, plugins) - Default WordPress metrics (users, posts, comments, plugins)
- Runtime metrics (HTTP requests, database queries)
- Cron job and transient cache metrics
- WooCommerce integration (products, orders, revenue)
- Custom metric builder with admin UI
- Grafana dashboard templates with download
- Extensible by other plugins using hooks - Extensible by other plugins using hooks
- Dashboard extension hook for third-party Grafana dashboards
- Settings page under Settings > Metrics - Settings page under Settings > Metrics
- Bearer token authentication - Bearer token authentication
- License management integration - License management integration
@@ -92,6 +98,21 @@ scrape_configs:
**Note:** Runtime metrics aggregate data across requests. Enable only the metrics you need to minimize performance impact. **Note:** Runtime metrics aggregate data across requests. Enable only the metrics you need to minimize performance impact.
#### Enabling Database Query Timing
The `wordpress_db_query_duration_seconds` histogram requires WordPress's `SAVEQUERIES` constant to be enabled. Add this to your `wp-config.php`:
```php
define( 'SAVEQUERIES', true );
```
**Important considerations:**
- `SAVEQUERIES` has a performance overhead as it logs all queries with timing and call stacks
- Recommended for development/staging environments, use with caution in production
- Without `SAVEQUERIES`, only query counts (`wordpress_db_queries_total`) are available
- The histogram shows total query time per request, grouped by endpoint
### Cron Metrics (v0.2.0+) ### Cron Metrics (v0.2.0+)
| Metric | Type | Labels | Description | | Metric | Type | Labels | Description |
@@ -154,6 +175,51 @@ $histogram = $collector->register_histogram( $name, $help, $labels, $buckets );
$histogram->observe( $value, $labelValues ); $histogram->observe( $value, $labelValues );
``` ```
## Extending with Custom Dashboards (v0.4.6+)
Add your own Grafana dashboard templates using the `wp_prometheus_register_dashboards` action:
```php
add_action( 'wp_prometheus_register_dashboards', function( $provider ) {
// File-based dashboard
$provider->register_dashboard( 'my-plugin-dashboard', array(
'title' => __( 'My Plugin Metrics', 'my-plugin' ),
'description' => __( 'Dashboard for my custom metrics', 'my-plugin' ),
'icon' => 'dashicons-chart-bar',
'file' => MY_PLUGIN_PATH . 'assets/dashboards/my-dashboard.json',
'plugin' => 'My Plugin Name',
) );
// OR inline JSON dashboard
$provider->register_dashboard( 'dynamic-dashboard', array(
'title' => __( 'Dynamic Dashboard', 'my-plugin' ),
'description' => __( 'Dynamically generated dashboard', 'my-plugin' ),
'icon' => 'dashicons-admin-generic',
'json' => json_encode( $dashboard_array ),
'plugin' => 'My Plugin Name',
) );
} );
```
### Registration Parameters
| Parameter | Required | Description |
| --------- | -------- | ----------- |
| `title` | Yes | Dashboard title displayed in admin |
| `description` | No | Description shown below the title |
| `icon` | No | Dashicon class (default: `dashicons-chart-line`) |
| `file` | Yes* | Absolute path to JSON file |
| `json` | Yes* | Inline JSON content |
| `plugin` | No | Plugin name for attribution |
*Either `file` or `json` is required, but not both.
### Security Notes
- File paths must be absolute and within `wp-content/`
- Inline JSON is validated during registration
- Third-party dashboards are marked with an "Extension" badge in the admin UI
## Development ## Development
### Build for Release ### Build for Release

View File

@@ -16,7 +16,7 @@
.wp-prometheus-subtab-nav { .wp-prometheus-subtab-nav {
display: flex; display: flex;
margin: 0 0 20px 0; margin: 0;
padding: 0; padding: 0;
list-style: none; list-style: none;
border-bottom: 1px solid #c3c4c7; border-bottom: 1px solid #c3c4c7;
@@ -216,6 +216,32 @@
font-size: 13px; font-size: 13px;
} }
/* Third-party dashboard card styling */
.wp-prometheus-dashboard-card.third-party {
position: relative;
border-color: #2271b1;
}
.wp-prometheus-dashboard-card .dashboard-badge {
position: absolute;
top: -8px;
right: -8px;
background: #2271b1;
color: #fff;
font-size: 10px;
font-weight: 600;
padding: 3px 8px;
border-radius: 10px;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.wp-prometheus-dashboard-card .dashboard-plugin {
color: #646970;
margin: -5px 0 15px 0;
font-style: italic;
}
/* Import options panel */ /* Import options panel */
#import-options { #import-options {
border-radius: 4px; border-radius: 4px;

View File

@@ -946,6 +946,95 @@
], ],
"title": "Average Query Duration (Overall)", "title": "Average Query Duration (Overall)",
"type": "stat" "type": "stat"
},
{
"datasource": {
"type": "prometheus",
"uid": "${DS_PROMETHEUS}"
},
"fieldConfig": {
"defaults": {
"color": {
"mode": "palette-classic"
},
"custom": {
"axisBorderShow": false,
"axisCenteredZero": false,
"axisColorMode": "text",
"axisLabel": "",
"axisPlacement": "auto",
"fillOpacity": 80,
"gradientMode": "none",
"hideFrom": {
"legend": false,
"tooltip": false,
"viz": false
},
"lineWidth": 1,
"scaleDistribution": {
"type": "linear"
},
"thresholdsStyle": {
"mode": "off"
}
},
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": null
}
]
}
},
"overrides": []
},
"gridPos": {
"h": 8,
"w": 24,
"x": 0,
"y": 39
},
"id": 15,
"options": {
"barRadius": 0,
"barWidth": 0.97,
"fullHighlight": false,
"groupWidth": 0.7,
"legend": {
"calcs": [],
"displayMode": "list",
"placement": "bottom",
"showLegend": true
},
"orientation": "horizontal",
"showValue": "auto",
"stacking": "none",
"tooltip": {
"mode": "single",
"sort": "none"
},
"xTickLabelRotation": 0,
"xTickLabelSpacing": 0
},
"targets": [
{
"datasource": {
"type": "prometheus",
"uid": "${DS_PROMETHEUS}"
},
"editorMode": "code",
"expr": "sum(wordpress_db_query_duration_seconds_bucket) by (le)",
"format": "table",
"instant": true,
"legendFormat": "{{le}}",
"refId": "A"
}
],
"title": "Query Duration Distribution (Buckets)",
"type": "barchart"
} }
], ],
"refresh": "30s", "refresh": "30s",

Binary file not shown.

View File

@@ -317,6 +317,10 @@ msgstr "HTTP-Anfragedauer-Verteilung"
msgid "Database queries by endpoint" msgid "Database queries by endpoint"
msgstr "Datenbank-Abfragen nach Endpunkt" msgstr "Datenbank-Abfragen nach Endpunkt"
#: src/Admin/Settings.php
msgid "Database query duration distribution (requires SAVEQUERIES)"
msgstr "Datenbank-Abfragedauer-Verteilung (erfordert SAVEQUERIES)"
#: src/Admin/Settings.php #: src/Admin/Settings.php
msgid "Scheduled cron events by hook" msgid "Scheduled cron events by hook"
msgstr "Geplante Cron-Ereignisse nach Hook" msgstr "Geplante Cron-Ereignisse nach Hook"
@@ -947,3 +951,49 @@ msgstr "Laufzeit-Metriken erfassen HTTP-Anfragen und Datenbank-Abfragen ueber me
#: src/Admin/Settings.php #: src/Admin/Settings.php
msgid "Reset Data" msgid "Reset Data"
msgstr "Daten zuruecksetzen" msgstr "Daten zuruecksetzen"
#: src/Admin/Settings.php
msgid "Extension"
msgstr "Erweiterung"
#. translators: %s: Plugin name
#: src/Admin/Settings.php
msgid "Provided by: %s"
msgstr "Bereitgestellt von: %s"
#: src/Admin/Settings.php
msgid "No dashboards available."
msgstr "Keine Dashboards verfuegbar."
#: src/Admin/Settings.php
msgid "Pre-built dashboards for visualizing your WordPress metrics in Grafana."
msgstr "Vorgefertigte Dashboards zur Visualisierung Ihrer WordPress-Metriken in Grafana."
#: src/Admin/Settings.php
msgid "Installation Instructions"
msgstr "Installationsanleitung"
#: src/Admin/Settings.php
msgid "Download the JSON file for your desired dashboard."
msgstr "Laden Sie die JSON-Datei fuer das gewuenschte Dashboard herunter."
#: src/Admin/Settings.php
msgid "In Grafana, go to Dashboards → Import."
msgstr "Gehen Sie in Grafana zu Dashboards → Import."
#: src/Admin/Settings.php
msgid "Upload the JSON file or paste its contents."
msgstr "Laden Sie die JSON-Datei hoch oder fuegen Sie den Inhalt ein."
#: src/Admin/Settings.php
msgid "Select your Prometheus data source when prompted."
msgstr "Waehlen Sie Ihre Prometheus-Datenquelle, wenn Sie dazu aufgefordert werden."
#: src/Admin/Settings.php
msgid "Click Import to create the dashboard."
msgstr "Klicken Sie auf Import, um das Dashboard zu erstellen."
#. translators: %s: Metrics URL
#: src/Admin/Settings.php
msgid "Make sure your Prometheus instance is configured to scrape %s with the correct authentication token."
msgstr "Stellen Sie sicher, dass Ihre Prometheus-Instanz so konfiguriert ist, dass sie %s mit dem richtigen Authentifizierungs-Token abruft."

View File

@@ -314,6 +314,10 @@ msgstr ""
msgid "Database queries by endpoint" msgid "Database queries by endpoint"
msgstr "" msgstr ""
#: src/Admin/Settings.php
msgid "Database query duration distribution (requires SAVEQUERIES)"
msgstr ""
#: src/Admin/Settings.php #: src/Admin/Settings.php
msgid "Scheduled cron events by hook" msgid "Scheduled cron events by hook"
msgstr "" msgstr ""
@@ -944,3 +948,49 @@ msgstr ""
#: src/Admin/Settings.php #: src/Admin/Settings.php
msgid "Reset Data" msgid "Reset Data"
msgstr "" msgstr ""
#: src/Admin/Settings.php
msgid "Extension"
msgstr ""
#. translators: %s: Plugin name
#: src/Admin/Settings.php
msgid "Provided by: %s"
msgstr ""
#: src/Admin/Settings.php
msgid "No dashboards available."
msgstr ""
#: src/Admin/Settings.php
msgid "Pre-built dashboards for visualizing your WordPress metrics in Grafana."
msgstr ""
#: src/Admin/Settings.php
msgid "Installation Instructions"
msgstr ""
#: src/Admin/Settings.php
msgid "Download the JSON file for your desired dashboard."
msgstr ""
#: src/Admin/Settings.php
msgid "In Grafana, go to Dashboards → Import."
msgstr ""
#: src/Admin/Settings.php
msgid "Upload the JSON file or paste its contents."
msgstr ""
#: src/Admin/Settings.php
msgid "Select your Prometheus data source when prompted."
msgstr ""
#: src/Admin/Settings.php
msgid "Click Import to create the dashboard."
msgstr ""
#. translators: %s: Metrics URL
#: src/Admin/Settings.php
msgid "Make sure your Prometheus instance is configured to scrape %s with the correct authentication token."
msgstr ""

View File

@@ -16,22 +16,37 @@ if ( ! defined( 'ABSPATH' ) ) {
* DashboardProvider class. * DashboardProvider class.
* *
* Provides Grafana dashboard templates for download. * Provides Grafana dashboard templates for download.
* Supports both built-in dashboards and third-party registrations.
*/ */
class DashboardProvider { class DashboardProvider {
/** /**
* Dashboard directory path. * Dashboard directory path for built-in dashboards.
* *
* @var string * @var string
*/ */
private string $dashboard_dir; private string $dashboard_dir;
/** /**
* Available dashboard definitions. * Built-in dashboard definitions.
* *
* @var array * @var array
*/ */
private array $dashboards = array(); private array $builtin_dashboards = array();
/**
* Third-party registered dashboard definitions.
*
* @var array
*/
private array $registered_dashboards = array();
/**
* Whether the registration hook has been fired.
*
* @var bool
*/
private bool $hook_fired = false;
/** /**
* Constructor. * Constructor.
@@ -39,43 +54,241 @@ class DashboardProvider {
public function __construct() { public function __construct() {
$this->dashboard_dir = WP_PROMETHEUS_PATH . 'assets/dashboards/'; $this->dashboard_dir = WP_PROMETHEUS_PATH . 'assets/dashboards/';
$this->dashboards = array( $this->builtin_dashboards = array(
'wordpress-overview' => array( 'wordpress-overview' => array(
'title' => __( 'WordPress Overview', 'wp-prometheus' ), 'title' => __( 'WordPress Overview', 'wp-prometheus' ),
'description' => __( 'General WordPress metrics including users, posts, comments, and plugins.', 'wp-prometheus' ), 'description' => __( 'General WordPress metrics including users, posts, comments, and plugins.', 'wp-prometheus' ),
'file' => 'wordpress-overview.json', 'file' => 'wordpress-overview.json',
'icon' => 'dashicons-wordpress', 'icon' => 'dashicons-wordpress',
'source' => 'builtin',
), ),
'wordpress-runtime' => array( 'wordpress-runtime' => array(
'title' => __( 'Runtime Performance', 'wp-prometheus' ), 'title' => __( 'Runtime Performance', 'wp-prometheus' ),
'description' => __( 'HTTP request metrics, database query performance, and response times.', 'wp-prometheus' ), 'description' => __( 'HTTP request metrics, database query performance, and response times.', 'wp-prometheus' ),
'file' => 'wordpress-runtime.json', 'file' => 'wordpress-runtime.json',
'icon' => 'dashicons-performance', 'icon' => 'dashicons-performance',
'source' => 'builtin',
), ),
'wordpress-woocommerce' => array( 'wordpress-woocommerce' => array(
'title' => __( 'WooCommerce Store', 'wp-prometheus' ), 'title' => __( 'WooCommerce Store', 'wp-prometheus' ),
'description' => __( 'WooCommerce metrics including products, orders, revenue, and customers.', 'wp-prometheus' ), 'description' => __( 'WooCommerce metrics including products, orders, revenue, and customers.', 'wp-prometheus' ),
'file' => 'wordpress-woocommerce.json', 'file' => 'wordpress-woocommerce.json',
'icon' => 'dashicons-cart', 'icon' => 'dashicons-cart',
'source' => 'builtin',
), ),
); );
} }
/**
* Register a third-party dashboard.
*
* @param string $slug Dashboard slug (unique identifier).
* @param array $args Dashboard configuration {
* @type string $title Dashboard title (required).
* @type string $description Dashboard description.
* @type string $icon Dashicon class (e.g., 'dashicons-chart-bar').
* @type string $file Absolute path to JSON file (mutually exclusive with 'json').
* @type string $json Inline JSON content (mutually exclusive with 'file').
* @type string $plugin Plugin name for attribution.
* }
* @return bool True if registered successfully, false otherwise.
*/
public function register_dashboard( string $slug, array $args ): bool {
// Sanitize slug - must be valid identifier.
$slug = sanitize_key( $slug );
if ( empty( $slug ) ) {
if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) {
// phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
error_log( 'WP Prometheus: Dashboard registration failed - invalid slug' );
}
return false;
}
// Check for duplicate slugs (built-in takes precedence).
if ( isset( $this->builtin_dashboards[ $slug ] ) ) {
if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) {
// phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
error_log( "WP Prometheus: Dashboard slug '$slug' conflicts with built-in dashboard" );
}
return false;
}
// Check for duplicate slugs in already registered dashboards.
if ( isset( $this->registered_dashboards[ $slug ] ) ) {
if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) {
// phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
error_log( "WP Prometheus: Dashboard slug '$slug' already registered" );
}
return false;
}
// Validate required fields.
if ( empty( $args['title'] ) ) {
if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) {
// phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
error_log( "WP Prometheus: Dashboard '$slug' missing required 'title'" );
}
return false;
}
// Must have either 'file' or 'json', not both.
$has_file = ! empty( $args['file'] );
$has_json = ! empty( $args['json'] );
if ( ! $has_file && ! $has_json ) {
if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) {
// phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
error_log( "WP Prometheus: Dashboard '$slug' must have 'file' or 'json'" );
}
return false;
}
if ( $has_file && $has_json ) {
if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) {
// phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
error_log( "WP Prometheus: Dashboard '$slug' cannot have both 'file' and 'json'" );
}
return false;
}
// Validate file path if provided.
if ( $has_file ) {
$file_path = $args['file'];
// Must be absolute path.
if ( ! path_is_absolute( $file_path ) ) {
if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) {
// phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
error_log( "WP Prometheus: Dashboard '$slug' file path must be absolute" );
}
return false;
}
// File must exist and be readable.
if ( ! file_exists( $file_path ) || ! is_readable( $file_path ) ) {
if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) {
// phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
error_log( "WP Prometheus: Dashboard '$slug' file not found: $file_path" );
}
return false;
}
// Security: Prevent path traversal - file must be under wp-content.
$real_path = realpath( $file_path );
$wp_content_dir = realpath( WP_CONTENT_DIR );
if ( false === $real_path || false === $wp_content_dir ||
strpos( $real_path, $wp_content_dir ) !== 0 ) {
if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) {
// phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
error_log( "WP Prometheus: Dashboard '$slug' file outside wp-content" );
}
return false;
}
}
// Validate JSON if provided inline.
if ( $has_json ) {
$decoded = json_decode( $args['json'], true );
if ( null === $decoded && json_last_error() !== JSON_ERROR_NONE ) {
if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) {
// phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
error_log( "WP Prometheus: Dashboard '$slug' has invalid JSON" );
}
return false;
}
}
// Build dashboard entry.
$this->registered_dashboards[ $slug ] = array(
'title' => sanitize_text_field( $args['title'] ),
'description' => sanitize_text_field( $args['description'] ?? '' ),
'icon' => sanitize_html_class( $args['icon'] ?? 'dashicons-chart-line' ),
'file' => $has_file ? $file_path : null,
'json' => $has_json ? $args['json'] : null,
'plugin' => sanitize_text_field( $args['plugin'] ?? '' ),
'source' => 'third-party',
);
return true;
}
/**
* Fire the dashboard registration hook with protection.
*
* @return void
*/
private function fire_registration_hook(): void {
if ( $this->hook_fired ) {
return;
}
$this->hook_fired = true;
// Check for isolated mode - skip third-party hooks.
$isolated_mode = defined( 'WP_PROMETHEUS_ISOLATED_MODE' ) && WP_PROMETHEUS_ISOLATED_MODE;
// Also check option for admin-side isolated mode.
if ( ! $isolated_mode ) {
$isolated_mode = (bool) get_option( 'wp_prometheus_isolated_mode', false );
}
if ( $isolated_mode ) {
return;
}
// Use output buffering to capture any accidental output.
ob_start();
try {
/**
* Fires to allow third-party plugins to register dashboards.
*
* @since 0.4.6
*
* @param DashboardProvider $provider The dashboard provider instance.
*/
do_action( 'wp_prometheus_register_dashboards', $this );
} catch ( \Throwable $e ) {
if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) {
// phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
error_log( 'WP Prometheus: Error in dashboard registration hook: ' . $e->getMessage() );
}
}
// Discard any output from plugins.
ob_end_clean();
}
/** /**
* Get list of available dashboards. * Get list of available dashboards.
* *
* @return array * @return array
*/ */
public function get_available(): array { public function get_available(): array {
// Fire registration hook first (only once).
$this->fire_registration_hook();
$available = array(); $available = array();
foreach ( $this->dashboards as $slug => $dashboard ) { // Add built-in dashboards (check file exists).
foreach ( $this->builtin_dashboards as $slug => $dashboard ) {
$file_path = $this->dashboard_dir . $dashboard['file']; $file_path = $this->dashboard_dir . $dashboard['file'];
if ( file_exists( $file_path ) ) { if ( file_exists( $file_path ) ) {
$available[ $slug ] = $dashboard; $available[ $slug ] = $dashboard;
} }
} }
// Add registered third-party dashboards.
foreach ( $this->registered_dashboards as $slug => $dashboard ) {
// Already validated during registration, but double-check.
if ( ! empty( $dashboard['json'] ) ||
( ! empty( $dashboard['file'] ) && file_exists( $dashboard['file'] ) ) ) {
$available[ $slug ] = $dashboard;
}
}
return $available; return $available;
} }
@@ -86,20 +299,23 @@ class DashboardProvider {
* @return string|null JSON content or null if not found. * @return string|null JSON content or null if not found.
*/ */
public function get_dashboard( string $slug ): ?string { public function get_dashboard( string $slug ): ?string {
// Validate slug to prevent directory traversal. // Fire registration hook first.
$this->fire_registration_hook();
// Validate slug.
$slug = sanitize_file_name( $slug ); $slug = sanitize_file_name( $slug );
if ( ! isset( $this->dashboards[ $slug ] ) ) { // Check built-in dashboards first.
return null; if ( isset( $this->builtin_dashboards[ $slug ] ) ) {
} $dashboard = $this->builtin_dashboards[ $slug ];
$file_path = $this->dashboard_dir . $dashboard['file'];
$file_path = $this->dashboard_dir . $this->dashboards[ $slug ]['file'];
// Security: Ensure file is within dashboard directory. // Security: Ensure file is within dashboard directory.
$real_path = realpath( $file_path ); $real_path = realpath( $file_path );
$real_dir = realpath( $this->dashboard_dir ); $real_dir = realpath( $this->dashboard_dir );
if ( false === $real_path || false === $real_dir || strpos( $real_path, $real_dir ) !== 0 ) { if ( false === $real_path || false === $real_dir ||
strpos( $real_path, $real_dir ) !== 0 ) {
return null; return null;
} }
@@ -110,11 +326,43 @@ class DashboardProvider {
// phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents
$content = file_get_contents( $file_path ); $content = file_get_contents( $file_path );
if ( false === $content ) { return false === $content ? null : $content;
}
// Check registered dashboards.
if ( isset( $this->registered_dashboards[ $slug ] ) ) {
$dashboard = $this->registered_dashboards[ $slug ];
// Inline JSON.
if ( ! empty( $dashboard['json'] ) ) {
return $dashboard['json'];
}
// File-based.
if ( ! empty( $dashboard['file'] ) ) {
$file_path = $dashboard['file'];
// Security: Re-verify file is under wp-content.
$real_path = realpath( $file_path );
$wp_content_dir = realpath( WP_CONTENT_DIR );
if ( false === $real_path || false === $wp_content_dir ||
strpos( $real_path, $wp_content_dir ) !== 0 ) {
return null; return null;
} }
return $content; if ( ! file_exists( $file_path ) || ! is_readable( $file_path ) ) {
return null;
}
// phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents
$content = file_get_contents( $file_path );
return false === $content ? null : $content;
}
}
return null;
} }
/** /**
@@ -124,13 +372,20 @@ class DashboardProvider {
* @return array|null Dashboard metadata or null if not found. * @return array|null Dashboard metadata or null if not found.
*/ */
public function get_metadata( string $slug ): ?array { public function get_metadata( string $slug ): ?array {
// Fire registration hook first.
$this->fire_registration_hook();
$slug = sanitize_file_name( $slug ); $slug = sanitize_file_name( $slug );
if ( ! isset( $this->dashboards[ $slug ] ) ) { if ( isset( $this->builtin_dashboards[ $slug ] ) ) {
return null; return $this->builtin_dashboards[ $slug ];
} }
return $this->dashboards[ $slug ]; if ( isset( $this->registered_dashboards[ $slug ] ) ) {
return $this->registered_dashboards[ $slug ];
}
return null;
} }
/** /**
@@ -140,12 +395,61 @@ class DashboardProvider {
* @return string|null Filename or null if not found. * @return string|null Filename or null if not found.
*/ */
public function get_filename( string $slug ): ?string { public function get_filename( string $slug ): ?string {
// Fire registration hook first.
$this->fire_registration_hook();
$slug = sanitize_file_name( $slug ); $slug = sanitize_file_name( $slug );
if ( ! isset( $this->dashboards[ $slug ] ) ) { // Built-in dashboards have predefined filenames.
if ( isset( $this->builtin_dashboards[ $slug ] ) ) {
return $this->builtin_dashboards[ $slug ]['file'];
}
// Registered dashboards - use file basename or generate from slug.
if ( isset( $this->registered_dashboards[ $slug ] ) ) {
$dashboard = $this->registered_dashboards[ $slug ];
if ( ! empty( $dashboard['file'] ) ) {
return basename( $dashboard['file'] );
}
// Generate filename from slug for inline JSON.
return $slug . '.json';
}
return null; return null;
} }
return $this->dashboards[ $slug ]['file']; /**
* Check if a dashboard is from a third-party plugin.
*
* @param string $slug Dashboard slug.
* @return bool True if third-party, false if built-in or not found.
*/
public function is_third_party( string $slug ): bool {
$this->fire_registration_hook();
$slug = sanitize_file_name( $slug );
return isset( $this->registered_dashboards[ $slug ] );
}
/**
* Get the plugin name for a third-party dashboard.
*
* @param string $slug Dashboard slug.
* @return string|null Plugin name or null if not found/built-in.
*/
public function get_plugin_name( string $slug ): ?string {
$this->fire_registration_hook();
$slug = sanitize_file_name( $slug );
if ( isset( $this->registered_dashboards[ $slug ] ) ) {
$plugin = $this->registered_dashboards[ $slug ]['plugin'];
return ! empty( $plugin ) ? $plugin : null;
}
return null;
} }
} }

View File

@@ -107,18 +107,20 @@ class Settings {
* @return void * @return void
*/ */
public function register_settings(): void { public function register_settings(): void {
// Register settings for metrics tab. // Register settings for endpoint sub-tab.
register_setting( 'wp_prometheus_metrics_settings', 'wp_prometheus_auth_token', array( register_setting( 'wp_prometheus_endpoint_settings', 'wp_prometheus_auth_token', array(
'type' => 'string', 'type' => 'string',
'sanitize_callback' => 'sanitize_text_field', 'sanitize_callback' => 'sanitize_text_field',
) ); ) );
register_setting( 'wp_prometheus_metrics_settings', 'wp_prometheus_enabled_metrics', array( // Register settings for selection sub-tab.
register_setting( 'wp_prometheus_selection_settings', 'wp_prometheus_enabled_metrics', array(
'type' => 'array', 'type' => 'array',
'sanitize_callback' => array( $this, 'sanitize_metrics' ), 'sanitize_callback' => array( $this, 'sanitize_metrics' ),
) ); ) );
register_setting( 'wp_prometheus_metrics_settings', 'wp_prometheus_disable_early_mode', array( // Register settings for advanced sub-tab.
register_setting( 'wp_prometheus_advanced_settings', 'wp_prometheus_isolated_mode', array(
'type' => 'boolean', 'type' => 'boolean',
'sanitize_callback' => 'rest_sanitize_boolean', 'sanitize_callback' => 'rest_sanitize_boolean',
'default' => false, 'default' => false,
@@ -484,7 +486,7 @@ class Settings {
private function render_metrics_endpoint_subtab(): void { private function render_metrics_endpoint_subtab(): void {
?> ?>
<form method="post" action="options.php"> <form method="post" action="options.php">
<?php settings_fields( 'wp_prometheus_metrics_settings' ); ?> <?php settings_fields( 'wp_prometheus_endpoint_settings' ); ?>
<h3><?php esc_html_e( 'Authentication', 'wp-prometheus' ); ?></h3> <h3><?php esc_html_e( 'Authentication', 'wp-prometheus' ); ?></h3>
<p class="description"><?php esc_html_e( 'Configure authentication for the /metrics endpoint.', 'wp-prometheus' ); ?></p> <p class="description"><?php esc_html_e( 'Configure authentication for the /metrics endpoint.', 'wp-prometheus' ); ?></p>
@@ -513,7 +515,7 @@ class Settings {
private function render_metrics_selection_subtab(): void { private function render_metrics_selection_subtab(): void {
?> ?>
<form method="post" action="options.php"> <form method="post" action="options.php">
<?php settings_fields( 'wp_prometheus_metrics_settings' ); ?> <?php settings_fields( 'wp_prometheus_selection_settings' ); ?>
<h3><?php esc_html_e( 'Enabled Metrics', 'wp-prometheus' ); ?></h3> <h3><?php esc_html_e( 'Enabled Metrics', 'wp-prometheus' ); ?></h3>
<p class="description"><?php esc_html_e( 'Select which metrics to expose on the /metrics endpoint.', 'wp-prometheus' ); ?></p> <p class="description"><?php esc_html_e( 'Select which metrics to expose on the /metrics endpoint.', 'wp-prometheus' ); ?></p>
@@ -566,57 +568,93 @@ class Settings {
* @return void * @return void
*/ */
private function render_metrics_advanced_subtab(): void { private function render_metrics_advanced_subtab(): void {
$disabled = get_option( 'wp_prometheus_disable_early_mode', false ); $isolated_mode = get_option( 'wp_prometheus_isolated_mode', false );
$env_override = false !== getenv( 'WP_PROMETHEUS_DISABLE_EARLY_MODE' ); $env_override = false !== getenv( 'WP_PROMETHEUS_ISOLATED_MODE' );
$early_active = defined( 'WP_PROMETHEUS_EARLY_METRICS' ) && WP_PROMETHEUS_EARLY_METRICS; $is_metrics_request = defined( 'WP_PROMETHEUS_METRICS_REQUEST' ) && WP_PROMETHEUS_METRICS_REQUEST;
$is_isolated = defined( 'WP_PROMETHEUS_ISOLATED_MODE' ) && WP_PROMETHEUS_ISOLATED_MODE;
?> ?>
<form method="post" action="options.php"> <form method="post" action="options.php">
<?php settings_fields( 'wp_prometheus_metrics_settings' ); ?> <?php settings_fields( 'wp_prometheus_advanced_settings' ); ?>
<h3><?php esc_html_e( 'Early Mode', 'wp-prometheus' ); ?></h3> <h3><?php esc_html_e( 'Metrics Collection Mode', 'wp-prometheus' ); ?></h3>
<p class="description">
<?php esc_html_e( 'Early mode intercepts /metrics requests before full WordPress initialization. This prevents memory exhaustion issues caused by some plugins (e.g., Twig-based themes/plugins) but disables the wp_prometheus_collect_metrics hook for custom metrics.', 'wp-prometheus' ); ?> <div class="notice notice-info inline" style="padding: 12px; margin: 15px 0;">
</p> <p><strong><?php esc_html_e( 'Safe Mode (Default)', 'wp-prometheus' ); ?></strong></p>
<p><?php esc_html_e( 'Content filters are removed early to prevent memory issues with Twig-based plugins, but WordPress loads normally. Third-party plugins can add custom metrics via the wp_prometheus_collect_metrics hook.', 'wp-prometheus' ); ?></p>
</div>
<?php if ( $env_override ) : ?> <?php if ( $env_override ) : ?>
<div class="notice notice-info inline" style="padding: 12px; margin: 15px 0;"> <div class="notice notice-warning inline" style="padding: 12px; margin: 15px 0;">
<strong><?php esc_html_e( 'Environment Override Active', 'wp-prometheus' ); ?></strong> <strong><?php esc_html_e( 'Environment Override Active', 'wp-prometheus' ); ?></strong>
<p><?php esc_html_e( 'Early mode is configured via WP_PROMETHEUS_DISABLE_EARLY_MODE environment variable. Admin settings will be ignored.', 'wp-prometheus' ); ?></p> <p><?php esc_html_e( 'Mode is configured via WP_PROMETHEUS_ISOLATED_MODE environment variable. Admin settings will be ignored.', 'wp-prometheus' ); ?></p>
</div> </div>
<?php endif; ?> <?php endif; ?>
<table class="form-table" role="presentation"> <table class="form-table" role="presentation">
<tr> <tr>
<th scope="row"><?php esc_html_e( 'Disable Early Mode', 'wp-prometheus' ); ?></th> <th scope="row"><?php esc_html_e( 'Isolated Mode', 'wp-prometheus' ); ?></th>
<td> <td>
<label> <label>
<input type="checkbox" name="wp_prometheus_disable_early_mode" value="1" <input type="checkbox" name="wp_prometheus_isolated_mode" value="1"
<?php checked( $disabled ); ?> <?php checked( $isolated_mode ); ?>
<?php disabled( $env_override ); ?>> <?php disabled( $env_override ); ?>>
<?php esc_html_e( 'Disable early metrics interception', 'wp-prometheus' ); ?> <?php esc_html_e( 'Enable isolated mode', 'wp-prometheus' ); ?>
</label> </label>
<p class="description"> <p class="description">
<?php esc_html_e( 'When disabled, metrics are collected through normal WordPress template loading. This enables the wp_prometheus_collect_metrics hook for custom metrics but may cause issues with some plugins.', 'wp-prometheus' ); ?> <?php esc_html_e( 'Isolated mode outputs metrics immediately before other plugins fully load. This provides maximum isolation but disables the wp_prometheus_collect_metrics hook. Use this only if you experience issues with Safe Mode.', 'wp-prometheus' ); ?>
</p> </p>
</td> </td>
</tr> </tr>
<tr> <tr>
<th scope="row"><?php esc_html_e( 'Current Status', 'wp-prometheus' ); ?></th> <th scope="row"><?php esc_html_e( 'Current Status', 'wp-prometheus' ); ?></th>
<td> <td>
<?php if ( $early_active ) : ?> <?php if ( $is_isolated ) : ?>
<span class="dashicons dashicons-lock" style="color: orange;"></span>
<?php esc_html_e( 'Isolated mode active - custom hooks are disabled', 'wp-prometheus' ); ?>
<?php elseif ( $is_metrics_request ) : ?>
<span class="dashicons dashicons-yes-alt" style="color: green;"></span> <span class="dashicons dashicons-yes-alt" style="color: green;"></span>
<?php esc_html_e( 'Early mode is active (this request was served via early interception)', 'wp-prometheus' ); ?> <?php esc_html_e( 'Safe mode active - custom hooks enabled with filter protection', 'wp-prometheus' ); ?>
<?php elseif ( $disabled || $env_override ) : ?> <?php elseif ( $isolated_mode ) : ?>
<span class="dashicons dashicons-dismiss" style="color: gray;"></span> <span class="dashicons dashicons-lock" style="color: orange;"></span>
<?php esc_html_e( 'Early mode is disabled', 'wp-prometheus' ); ?> <?php esc_html_e( 'Isolated mode enabled (active for /metrics requests)', 'wp-prometheus' ); ?>
<?php else : ?> <?php else : ?>
<span class="dashicons dashicons-yes-alt" style="color: green;"></span> <span class="dashicons dashicons-yes-alt" style="color: green;"></span>
<?php esc_html_e( 'Early mode is enabled (active for /metrics requests)', 'wp-prometheus' ); ?> <?php esc_html_e( 'Safe mode enabled (default) - custom hooks with filter protection', 'wp-prometheus' ); ?>
<?php endif; ?> <?php endif; ?>
</td> </td>
</tr> </tr>
</table> </table>
<hr style="margin: 20px 0;">
<h4><?php esc_html_e( 'Mode Comparison', 'wp-prometheus' ); ?></h4>
<table class="widefat striped" style="max-width: 700px;">
<thead>
<tr>
<th><?php esc_html_e( 'Feature', 'wp-prometheus' ); ?></th>
<th><?php esc_html_e( 'Safe Mode', 'wp-prometheus' ); ?></th>
<th><?php esc_html_e( 'Isolated Mode', 'wp-prometheus' ); ?></th>
</tr>
</thead>
<tbody>
<tr>
<td><?php esc_html_e( 'Custom metrics hook', 'wp-prometheus' ); ?></td>
<td><span class="dashicons dashicons-yes" style="color: green;"></span></td>
<td><span class="dashicons dashicons-no" style="color: red;"></span></td>
</tr>
<tr>
<td><?php esc_html_e( 'Plugin compatibility', 'wp-prometheus' ); ?></td>
<td><?php esc_html_e( 'High', 'wp-prometheus' ); ?></td>
<td><?php esc_html_e( 'Maximum', 'wp-prometheus' ); ?></td>
</tr>
<tr>
<td><?php esc_html_e( 'Memory usage', 'wp-prometheus' ); ?></td>
<td><?php esc_html_e( 'Normal', 'wp-prometheus' ); ?></td>
<td><?php esc_html_e( 'Minimal', 'wp-prometheus' ); ?></td>
</tr>
</tbody>
</table>
<?php submit_button(); ?> <?php submit_button(); ?>
</form> </form>
<?php <?php
@@ -1176,20 +1214,45 @@ class Settings {
<h2><?php esc_html_e( 'Grafana Dashboard Templates', 'wp-prometheus' ); ?></h2> <h2><?php esc_html_e( 'Grafana Dashboard Templates', 'wp-prometheus' ); ?></h2>
<p class="description"><?php esc_html_e( 'Pre-built dashboards for visualizing your WordPress metrics in Grafana.', 'wp-prometheus' ); ?></p> <p class="description"><?php esc_html_e( 'Pre-built dashboards for visualizing your WordPress metrics in Grafana.', 'wp-prometheus' ); ?></p>
<?php if ( empty( $dashboards ) ) : ?>
<p class="description"><?php esc_html_e( 'No dashboards available.', 'wp-prometheus' ); ?></p>
<?php else : ?>
<div class="wp-prometheus-dashboard-grid"> <div class="wp-prometheus-dashboard-grid">
<?php foreach ( $dashboards as $slug => $dashboard ) : ?> <?php
<div class="wp-prometheus-dashboard-card"> foreach ( $dashboards as $slug => $dashboard ) :
$is_third_party = $this->dashboard_provider->is_third_party( $slug );
$plugin_name = $this->dashboard_provider->get_plugin_name( $slug );
$card_class = 'wp-prometheus-dashboard-card' . ( $is_third_party ? ' third-party' : '' );
?>
<div class="<?php echo esc_attr( $card_class ); ?>">
<?php if ( $is_third_party ) : ?>
<span class="dashboard-badge"><?php esc_html_e( 'Extension', 'wp-prometheus' ); ?></span>
<?php endif; ?>
<div class="dashboard-icon"> <div class="dashboard-icon">
<span class="dashicons <?php echo esc_attr( $dashboard['icon'] ); ?>"></span> <span class="dashicons <?php echo esc_attr( $dashboard['icon'] ); ?>"></span>
</div> </div>
<h3><?php echo esc_html( $dashboard['title'] ); ?></h3> <h3><?php echo esc_html( $dashboard['title'] ); ?></h3>
<p><?php echo esc_html( $dashboard['description'] ); ?></p> <p><?php echo esc_html( $dashboard['description'] ); ?></p>
<?php if ( $is_third_party && $plugin_name ) : ?>
<p class="dashboard-plugin">
<small>
<?php
printf(
/* translators: %s: Plugin name */
esc_html__( 'Provided by: %s', 'wp-prometheus' ),
esc_html( $plugin_name )
);
?>
</small>
</p>
<?php endif; ?>
<button type="button" class="button button-primary download-dashboard" data-slug="<?php echo esc_attr( $slug ); ?>"> <button type="button" class="button button-primary download-dashboard" data-slug="<?php echo esc_attr( $slug ); ?>">
<?php esc_html_e( 'Download', 'wp-prometheus' ); ?> <?php esc_html_e( 'Download', 'wp-prometheus' ); ?>
</button> </button>
</div> </div>
<?php endforeach; ?> <?php endforeach; ?>
</div> </div>
<?php endif; ?>
<hr style="margin: 30px 0;"> <hr style="margin: 30px 0;">
@@ -1302,6 +1365,11 @@ class Settings {
<td><?php esc_html_e( 'Counter', 'wp-prometheus' ); ?></td> <td><?php esc_html_e( 'Counter', 'wp-prometheus' ); ?></td>
<td><?php esc_html_e( 'Database queries by endpoint', 'wp-prometheus' ); ?></td> <td><?php esc_html_e( 'Database queries by endpoint', 'wp-prometheus' ); ?></td>
</tr> </tr>
<tr>
<td><code>wordpress_db_query_duration_seconds</code></td>
<td><?php esc_html_e( 'Histogram', 'wp-prometheus' ); ?></td>
<td><?php esc_html_e( 'Database query duration distribution (requires SAVEQUERIES)', 'wp-prometheus' ); ?></td>
</tr>
<tr> <tr>
<td><code>wordpress_cron_events_total</code></td> <td><code>wordpress_cron_events_total</code></td>
<td><?php esc_html_e( 'Gauge', 'wp-prometheus' ); ?></td> <td><?php esc_html_e( 'Gauge', 'wp-prometheus' ); ?></td>

View File

@@ -121,14 +121,62 @@ class Collector {
/** /**
* Fires after default metrics are collected. * Fires after default metrics are collected.
* *
* Skip in early metrics mode to avoid triggering third-party hooks * In isolated mode, skip custom hooks to avoid any potential issues.
* that may cause recursion issues (e.g., Twig-based plugins). * In safe mode (default), fire hooks with protection against recursion.
* *
* @param Collector $collector The metrics collector instance. * @param Collector $collector The metrics collector instance.
*/ */
if ( ! defined( 'WP_PROMETHEUS_EARLY_METRICS' ) || ! WP_PROMETHEUS_EARLY_METRICS ) { if ( defined( 'WP_PROMETHEUS_ISOLATED_MODE' ) && WP_PROMETHEUS_ISOLATED_MODE ) {
do_action( 'wp_prometheus_collect_metrics', $this ); // Isolated mode: skip all third-party hooks for maximum safety.
return;
} }
// Safe mode: fire custom hooks with protection.
$this->fire_custom_metrics_hook();
}
/**
* Fire custom metrics hook with protection against recursion.
*
* Removes potentially problematic filters, uses output buffering,
* and catches any errors from third-party plugins.
*
* @return void
*/
private function fire_custom_metrics_hook(): void {
// Remove content filters again (in case any plugin re-added them).
if ( function_exists( 'wp_prometheus_remove_content_filters' ) ) {
wp_prometheus_remove_content_filters();
} else {
// Fallback if function doesn't exist.
remove_all_filters( 'the_content' );
remove_all_filters( 'the_excerpt' );
remove_all_filters( 'get_the_excerpt' );
remove_all_filters( 'the_title' );
}
// Use output buffering to prevent any accidental output from plugins.
ob_start();
try {
/**
* Fires after default metrics are collected.
*
* Third-party plugins can use this hook to add custom metrics.
*
* @param Collector $collector The metrics collector instance.
*/
do_action( 'wp_prometheus_collect_metrics', $this );
} catch ( \Throwable $e ) {
// Log the error but don't let it break metrics output.
if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) {
// phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
error_log( 'WP Prometheus: Error in custom metrics hook: ' . $e->getMessage() );
}
}
// Discard any output from plugins.
ob_end_clean();
} }
/** /**

View File

@@ -3,7 +3,7 @@
* Plugin Name: WP Prometheus * Plugin Name: WP Prometheus
* Plugin URI: https://src.bundespruefstelle.ch/magdev/wp-prometheus * Plugin URI: https://src.bundespruefstelle.ch/magdev/wp-prometheus
* Description: Prometheus metrics endpoint for WordPress with extensible hooks for custom metrics. * Description: Prometheus metrics endpoint for WordPress with extensible hooks for custom metrics.
* Version: 0.4.3 * Version: 0.4.7
* Requires at least: 6.4 * Requires at least: 6.4
* Requires PHP: 8.3 * Requires PHP: 8.3
* Author: Marco Graetsch * Author: Marco Graetsch
@@ -22,11 +22,16 @@ if ( ! defined( 'ABSPATH' ) ) {
} }
/** /**
* Early metrics endpoint handler. * Early metrics request detection.
* *
* Intercepts /metrics requests before full WordPress initialization to avoid * Detects /metrics requests early and removes problematic content filters
* conflicts with other plugins that may cause issues during template loading. * to prevent recursion issues with Twig-based plugins. Unlike the previous
* This runs at plugin load time, before plugins_loaded hook. * "early mode", this allows WordPress to continue loading so that third-party
* plugins can register their wp_prometheus_collect_metrics hooks.
*
* Two modes are available:
* - Safe mode (default): Removes filters early, lets WP load, fires custom hooks
* - Isolated mode: Outputs metrics immediately without custom hooks (legacy early mode)
*/ */
function wp_prometheus_early_metrics_check(): void { function wp_prometheus_early_metrics_check(): void {
// Only handle /metrics requests. // Only handle /metrics requests.
@@ -37,18 +42,58 @@ function wp_prometheus_early_metrics_check(): void {
return; return;
} }
// Check if early mode is disabled via environment variable. // Set flag to indicate we're handling a metrics request.
$env_disable = getenv( 'WP_PROMETHEUS_DISABLE_EARLY_MODE' ); define( 'WP_PROMETHEUS_METRICS_REQUEST', true );
if ( false !== $env_disable && in_array( strtolower( $env_disable ), array( '1', 'true', 'yes', 'on' ), true ) ) {
return; // Check if isolated mode is enabled via environment variable.
$env_isolated = getenv( 'WP_PROMETHEUS_ISOLATED_MODE' );
$isolated_mode = false !== $env_isolated && in_array( strtolower( $env_isolated ), array( '1', 'true', 'yes', 'on' ), true );
// Check if isolated mode is enabled via option (legacy "early mode" setting).
if ( ! $isolated_mode && ! get_option( 'wp_prometheus_disable_early_mode', false ) ) {
// Check for legacy isolated mode option.
$isolated_mode = (bool) get_option( 'wp_prometheus_isolated_mode', false );
} }
// Check if early mode is disabled via option. // Remove all content filters immediately to prevent recursion with Twig-based plugins.
// We can use get_option() here because WordPress core is already loaded. // This is done for BOTH safe mode and isolated mode.
if ( get_option( 'wp_prometheus_disable_early_mode', false ) ) { add_action( 'plugins_loaded', 'wp_prometheus_remove_content_filters', 0 );
return;
// Also remove filters now in case they were added by mu-plugins.
wp_prometheus_remove_content_filters();
// If isolated mode is enabled, handle metrics immediately without waiting for plugins.
if ( $isolated_mode ) {
wp_prometheus_isolated_metrics_handler();
}
} }
/**
* Remove content filters that can cause recursion.
*
* Called early during metrics requests to prevent infinite loops
* with Twig-based plugins that hook into content filters.
*
* @return void
*/
function wp_prometheus_remove_content_filters(): void {
remove_all_filters( 'the_content' );
remove_all_filters( 'the_excerpt' );
remove_all_filters( 'get_the_excerpt' );
remove_all_filters( 'the_title' );
remove_all_filters( 'the_content_feed' );
remove_all_filters( 'comment_text' );
}
/**
* Handle metrics in isolated mode (no custom hooks).
*
* This is the legacy "early mode" that outputs metrics immediately
* without allowing third-party plugins to add custom metrics.
*
* @return void
*/
function wp_prometheus_isolated_metrics_handler(): void {
// Check if autoloader exists. // Check if autoloader exists.
$autoloader = __DIR__ . '/vendor/autoload.php'; $autoloader = __DIR__ . '/vendor/autoload.php';
if ( ! file_exists( $autoloader ) ) { if ( ! file_exists( $autoloader ) ) {
@@ -99,14 +144,8 @@ function wp_prometheus_early_metrics_check(): void {
exit; exit;
} }
// Set flag to indicate early metrics mode - Collector will skip extensibility hooks. // Set flag to indicate isolated mode - Collector will skip extensibility hooks.
define( 'WP_PROMETHEUS_EARLY_METRICS', true ); define( 'WP_PROMETHEUS_ISOLATED_MODE', true );
// Remove all content filters to prevent recursion with Twig-based plugins.
remove_all_filters( 'the_content' );
remove_all_filters( 'the_excerpt' );
remove_all_filters( 'get_the_excerpt' );
remove_all_filters( 'the_title' );
// Output metrics and exit immediately. // Output metrics and exit immediately.
$collector = new \Magdev\WpPrometheus\Metrics\Collector(); $collector = new \Magdev\WpPrometheus\Metrics\Collector();
@@ -130,7 +169,7 @@ wp_prometheus_early_metrics_check();
* *
* @var string * @var string
*/ */
define( 'WP_PROMETHEUS_VERSION', '0.4.3' ); define( 'WP_PROMETHEUS_VERSION', '0.4.7' );
/** /**
* Plugin file path. * Plugin file path.