6 Commits

Author SHA1 Message Date
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
192da4588a fix: Update plugin header version to 0.4.3
All checks were successful
Create Release Package / build-release (push) Successful in 59s
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-02 21:49:26 +01:00
cf1797d4bf feat: Split Metrics tab into sub-tabs and fix early mode storage (v0.4.3)
Some checks failed
Create Release Package / build-release (push) Failing after 52s
- Add sub-tabs: Endpoint, Selection, Runtime, Advanced
- Fix early mode checkbox not saving (was outside form element)
- Add CSS styling for horizontal sub-tab navigation
- Update translations with new sub-tab strings

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-02 21:45:36 +01:00
12 changed files with 1305 additions and 144 deletions

View File

@@ -5,18 +5,94 @@ 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.2] - 2026-02-02 ## [0.4.7] - 2026-02-03
### Added ### Added
- Option to disable early mode in admin settings (Metrics tab) - Database query duration distribution panel in Grafana Runtime dashboard
- Support for `WP_PROMETHEUS_DISABLE_EARLY_MODE` environment variable - `wordpress_db_query_duration_seconds` metric now listed in Help tab
- Early mode status display in settings - Documentation for enabling `SAVEQUERIES` constant for query timing
### Changed ### Changed
- Early mode can now be disabled for users who need the `wp_prometheus_collect_metrics` hook for custom metrics - Updated README with instructions for enabling database query timing
- Updated translations with new early mode strings (English and German) - 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
### Added
- Sub-tabs navigation within Metrics tab (Endpoint, Selection, Runtime, Advanced)
- Option to disable early mode in admin settings (Metrics → Advanced)
- Support for `WP_PROMETHEUS_DISABLE_EARLY_MODE` environment variable
- Early mode status display in settings
### Fixed
- Early mode setting now saves correctly (moved into form with proper settings group)
### Changed
- Reorganized Metrics tab into logical sub-sections for better usability
- Early mode can now be disabled for users who need the `wp_prometheus_collect_metrics` hook
- Updated translations with sub-tab and early mode strings (English and German)
## [0.4.1] - 2026-02-02 ## [0.4.1] - 2026-02-02

103
CLAUDE.md
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
@@ -291,6 +292,106 @@ 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)
- Split Metrics tab into sub-tabs for better organization:
- **Endpoint**: Authentication token configuration
- **Selection**: Enable/disable individual metrics
- **Runtime**: Reset runtime metrics data
- **Advanced**: Early mode toggle and status
- Fixed early mode setting not being saved (was outside form element)
- Added CSS styling for horizontal sub-tab navigation
- **Key Learning**: WordPress Settings API form structure
- Settings must be inside `<form action="options.php">` with `settings_fields()` call
- Each sub-tab needs its own form wrapper for proper saving
- 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)
- Added option to disable early mode for users who need extensibility - Added option to disable early mode for users who need extensibility

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

@@ -9,6 +9,61 @@
margin-top: 20px; margin-top: 20px;
} }
/* Sub-tabs navigation */
.wp-prometheus-subtabs {
margin-top: 15px;
}
.wp-prometheus-subtab-nav {
display: flex;
margin: 0;
padding: 0;
list-style: none;
border-bottom: 1px solid #c3c4c7;
}
.wp-prometheus-subtab-item {
margin: 0;
padding: 0;
}
.wp-prometheus-subtab-item a {
display: block;
padding: 8px 16px;
text-decoration: none;
color: #50575e;
border: 1px solid transparent;
border-bottom: none;
margin-bottom: -1px;
background: transparent;
font-size: 13px;
font-weight: 400;
}
.wp-prometheus-subtab-item a:hover {
color: #2271b1;
background: #f6f7f7;
}
.wp-prometheus-subtab-item.active a {
color: #1d2327;
background: #fff;
border-color: #c3c4c7;
border-bottom-color: #fff;
font-weight: 600;
}
.wp-prometheus-subtab-content {
background: #fff;
border: 1px solid #c3c4c7;
border-top: none;
padding: 20px;
}
.wp-prometheus-subtab-content h3:first-child {
margin-top: 0;
}
/* License status box */ /* License status box */
.wp-prometheus-license-status { .wp-prometheus-license-status {
margin: 15px 0; margin: 15px 0;
@@ -161,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"
@@ -919,3 +923,77 @@ msgstr "Fruehzeitiger Modus ist aktiviert (aktiv fuer /metrics-Anfragen)"
#: src/Admin/Settings.php #: src/Admin/Settings.php
msgid "Clear all accumulated runtime metric data (HTTP requests, database queries). This is useful for testing or starting fresh." msgid "Clear all accumulated runtime metric data (HTTP requests, database queries). This is useful for testing or starting fresh."
msgstr "Alle gesammelten Laufzeit-Metrikdaten loeschen (HTTP-Anfragen, Datenbank-Abfragen). Dies ist nuetzlich zum Testen oder fuer einen Neuanfang." msgstr "Alle gesammelten Laufzeit-Metrikdaten loeschen (HTTP-Anfragen, Datenbank-Abfragen). Dies ist nuetzlich zum Testen oder fuer einen Neuanfang."
#: src/Admin/Settings.php
msgid "Endpoint"
msgstr "Endpunkt"
#: src/Admin/Settings.php
msgid "Selection"
msgstr "Auswahl"
#: src/Admin/Settings.php
msgid "Runtime"
msgstr "Laufzeit"
#: src/Admin/Settings.php
msgid "Advanced"
msgstr "Erweitert"
#: src/Admin/Settings.php
msgid "Runtime Metrics Management"
msgstr "Laufzeit-Metriken Verwaltung"
#: src/Admin/Settings.php
msgid "Runtime metrics track HTTP requests and database queries across requests. Use this section to manage accumulated data."
msgstr "Laufzeit-Metriken erfassen HTTP-Anfragen und Datenbank-Abfragen ueber mehrere Anfragen hinweg. Verwenden Sie diesen Bereich zur Verwaltung der gesammelten Daten."
#: src/Admin/Settings.php
msgid "Reset Data"
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 ""
@@ -916,3 +920,77 @@ msgstr ""
#: src/Admin/Settings.php #: src/Admin/Settings.php
msgid "Clear all accumulated runtime metric data (HTTP requests, database queries). This is useful for testing or starting fresh." msgid "Clear all accumulated runtime metric data (HTTP requests, database queries). This is useful for testing or starting fresh."
msgstr "" msgstr ""
#: src/Admin/Settings.php
msgid "Endpoint"
msgstr ""
#: src/Admin/Settings.php
msgid "Selection"
msgstr ""
#: src/Admin/Settings.php
msgid "Runtime"
msgstr ""
#: src/Admin/Settings.php
msgid "Advanced"
msgstr ""
#: src/Admin/Settings.php
msgid "Runtime Metrics Management"
msgstr ""
#: src/Admin/Settings.php
msgid "Runtime metrics track HTTP requests and database queries across requests. Use this section to manage accumulated data."
msgstr ""
#: src/Admin/Settings.php
msgid "Reset Data"
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,
@@ -400,92 +402,261 @@ class Settings {
<?php <?php
} }
/**
* Get metrics sub-tabs.
*
* @return array
*/
private function get_metrics_subtabs(): array {
return array(
'endpoint' => __( 'Endpoint', 'wp-prometheus' ),
'selection' => __( 'Selection', 'wp-prometheus' ),
'runtime' => __( 'Runtime', 'wp-prometheus' ),
'advanced' => __( 'Advanced', 'wp-prometheus' ),
);
}
/**
* Get current metrics sub-tab.
*
* @return string
*/
private function get_current_metrics_subtab(): string {
$subtab = isset( $_GET['subtab'] ) ? sanitize_key( $_GET['subtab'] ) : 'endpoint';
$subtabs = $this->get_metrics_subtabs();
return array_key_exists( $subtab, $subtabs ) ? $subtab : 'endpoint';
}
/** /**
* Render metrics tab content. * Render metrics tab content.
* *
* @return void * @return void
*/ */
private function render_metrics_tab(): void { private function render_metrics_tab(): void {
$subtabs = $this->get_metrics_subtabs();
$current_subtab = $this->get_current_metrics_subtab();
?> ?>
<form method="post" action="options.php"> <div class="wp-prometheus-subtabs">
<ul class="wp-prometheus-subtab-nav">
<?php foreach ( $subtabs as $subtab_id => $subtab_name ) : ?>
<?php <?php
settings_fields( 'wp_prometheus_metrics_settings' ); $subtab_url = add_query_arg(
do_settings_sections( 'wp-prometheus-metrics' ); array(
submit_button(); 'page' => 'wp-prometheus',
'tab' => 'metrics',
'subtab' => $subtab_id,
),
admin_url( 'options-general.php' )
);
$active_class = ( $current_subtab === $subtab_id ) ? ' active' : '';
?> ?>
</form> <li class="wp-prometheus-subtab-item<?php echo esc_attr( $active_class ); ?>">
<a href="<?php echo esc_url( $subtab_url ); ?>"><?php echo esc_html( $subtab_name ); ?></a>
</li>
<?php endforeach; ?>
</ul>
<hr style="margin: 30px 0;"> <div class="wp-prometheus-subtab-content">
<?php
<h3><?php esc_html_e( 'Reset Runtime Metrics', 'wp-prometheus' ); ?></h3> switch ( $current_subtab ) {
<p class="description"><?php esc_html_e( 'Clear all accumulated runtime metric data (HTTP requests, database queries). This is useful for testing or starting fresh.', 'wp-prometheus' ); ?></p> case 'endpoint':
<p> $this->render_metrics_endpoint_subtab();
<button type="button" id="wp-prometheus-reset-runtime" class="button button-secondary"> break;
<?php esc_html_e( 'Reset Runtime Metrics', 'wp-prometheus' ); ?> case 'selection':
</button> $this->render_metrics_selection_subtab();
<span id="wp-prometheus-reset-spinner" class="spinner" style="float: none;"></span> break;
</p> case 'runtime':
<div id="wp-prometheus-reset-message" style="display: none; margin-top: 10px;"></div> $this->render_metrics_runtime_subtab();
break;
<hr style="margin: 30px 0;"> case 'advanced':
$this->render_metrics_advanced_subtab();
<?php $this->render_early_mode_section(); ?> break;
}
?>
</div>
</div>
<?php <?php
} }
/** /**
* Render early mode section. * Render metrics endpoint sub-tab.
* *
* @return void * @return void
*/ */
private function render_early_mode_section(): void { private function render_metrics_endpoint_subtab(): void {
$disabled = get_option( 'wp_prometheus_disable_early_mode', false );
$env_override = false !== getenv( 'WP_PROMETHEUS_DISABLE_EARLY_MODE' );
$early_active = defined( 'WP_PROMETHEUS_EARLY_METRICS' ) && WP_PROMETHEUS_EARLY_METRICS;
?> ?>
<h3><?php esc_html_e( 'Early Mode', 'wp-prometheus' ); ?></h3> <form method="post" action="options.php">
<p class="description"> <?php settings_fields( 'wp_prometheus_endpoint_settings' ); ?>
<?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' ); ?>
<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>
<table class="form-table" role="presentation">
<tr>
<th scope="row">
<label for="wp_prometheus_auth_token"><?php esc_html_e( 'Auth Token', 'wp-prometheus' ); ?></label>
</th>
<td>
<?php $this->render_auth_token_field(); ?>
</td>
</tr>
</table>
<?php submit_button(); ?>
</form>
<?php
}
/**
* Render metrics selection sub-tab.
*
* @return void
*/
private function render_metrics_selection_subtab(): void {
?>
<form method="post" action="options.php">
<?php settings_fields( 'wp_prometheus_selection_settings' ); ?>
<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>
<table class="form-table" role="presentation">
<tr>
<th scope="row"><?php esc_html_e( 'Select Metrics', 'wp-prometheus' ); ?></th>
<td>
<?php $this->render_enabled_metrics_field(); ?>
</td>
</tr>
</table>
<?php submit_button(); ?>
</form>
<?php
}
/**
* Render metrics runtime sub-tab.
*
* @return void
*/
private function render_metrics_runtime_subtab(): void {
?>
<h3><?php esc_html_e( 'Runtime Metrics Management', 'wp-prometheus' ); ?></h3>
<p class="description"><?php esc_html_e( 'Runtime metrics track HTTP requests and database queries across requests. Use this section to manage accumulated data.', 'wp-prometheus' ); ?></p>
<table class="form-table" role="presentation">
<tr>
<th scope="row"><?php esc_html_e( 'Reset Data', 'wp-prometheus' ); ?></th>
<td>
<p class="description" style="margin-bottom: 10px;">
<?php esc_html_e( 'Clear all accumulated runtime metric data (HTTP requests, database queries). This is useful for testing or starting fresh.', 'wp-prometheus' ); ?>
</p> </p>
<button type="button" id="wp-prometheus-reset-runtime" class="button button-secondary">
<?php esc_html_e( 'Reset Runtime Metrics', 'wp-prometheus' ); ?>
</button>
<span id="wp-prometheus-reset-spinner" class="spinner" style="float: none;"></span>
<div id="wp-prometheus-reset-message" style="display: none; margin-top: 10px;"></div>
</td>
</tr>
</table>
<?php
}
/**
* Render metrics advanced sub-tab.
*
* @return void
*/
private function render_metrics_advanced_subtab(): void {
$isolated_mode = get_option( 'wp_prometheus_isolated_mode', false );
$env_override = false !== getenv( 'WP_PROMETHEUS_ISOLATED_MODE' );
$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">
<?php settings_fields( 'wp_prometheus_advanced_settings' ); ?>
<h3><?php esc_html_e( 'Metrics Collection Mode', 'wp-prometheus' ); ?></h3>
<div class="notice notice-info inline" style="padding: 12px; margin: 15px 0;">
<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(); ?>
</form>
<?php <?php
} }
@@ -1043,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;">
@@ -1169,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.2 * 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.2' ); define( 'WP_PROMETHEUS_VERSION', '0.4.7' );
/** /**
* Plugin file path. * Plugin file path.