12 Commits
v0.3.0 ... 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
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
19d75ab7b2 feat: Add option to disable early mode (v0.4.2)
All checks were successful
Create Release Package / build-release (push) Successful in 56s
- Add wp_prometheus_disable_early_mode option in admin settings
- Support WP_PROMETHEUS_DISABLE_EARLY_MODE environment variable
- Add Early Mode section in Metrics tab with status indicator
- Allow users to enable wp_prometheus_collect_metrics hook

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-02 21:09:14 +01:00
fa63857f5f docs: Update CLAUDE.md with v0.4.1 session history
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-02 20:25:46 +01:00
41f16a9fbd fix: Resolve memory exhaustion with Twig-based plugins (v0.4.1)
All checks were successful
Create Release Package / build-release (push) Successful in 57s
- Add early metrics endpoint handler to intercept /metrics before full WP init
- Remove content filters during metrics collection to prevent recursion
- Skip extensibility hooks in early metrics mode
- Change template_redirect to parse_request for earlier interception

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-02 20:23:29 +01:00
f984e3eb23 chore: Add Redis/APCu extensions to CI and update gitignore
- Add redis and apcu PHP extensions to release workflow for v0.4.0 storage support
- Add MARKETING.md to gitignore

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-02 16:18:21 +01:00
898af5e9d2 feat: Add persistent storage support for Redis and APCu (v0.4.0)
All checks were successful
Create Release Package / build-release (push) Successful in 56s
- Add StorageFactory class for storage adapter selection with fallback
- Support Redis storage for shared metrics across instances
- Support APCu storage for high-performance single-server deployments
- Add Storage tab in admin settings with configuration UI
- Add connection testing for Redis and APCu adapters
- Support environment variables for Docker/containerized deployments
- Update Collector to use StorageFactory instead of hardcoded InMemory
- Add all translations (English and German)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-02 16:15:53 +01:00
18 changed files with 3254 additions and 87 deletions

View File

@@ -18,7 +18,7 @@ jobs:
uses: shivammathur/setup-php@v2
with:
php-version: '8.3'
extensions: mbstring, xml, zip, intl, gettext
extensions: mbstring, xml, zip, intl, gettext, redis, apcu
tools: composer:v2
- name: Get version from tag

3
.gitignore vendored
View File

@@ -4,3 +4,6 @@ wp-plugins
wp-core
vendor/
releases/*
# Marketing texts (not for distribution)
MARKETING.md

View File

@@ -5,6 +5,137 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [0.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
### 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
### Fixed
- Fixed memory exhaustion when wp-fedistream (Twig-based) plugin is active
- Added early metrics endpoint handler that intercepts `/metrics` requests before full WordPress initialization
- Removed content filters (`the_content`, `the_excerpt`, `get_the_excerpt`, `the_title`) during metrics collection to prevent recursion
- Skip third-party extensibility hooks during early metrics mode to avoid conflicts
- Changed `template_redirect` hook to `parse_request` for earlier request interception
## [0.4.0] - 2026-02-02
### Added
- Persistent Storage Support:
- Redis storage adapter for shared metrics across multiple instances
- APCu storage adapter for single-server high-performance caching
- StorageFactory class for automatic adapter selection and fallback
- Connection testing with detailed error messages
- New "Storage" tab in admin settings:
- Storage adapter selection (In-Memory, Redis, APCu)
- Redis configuration (host, port, password, database, key prefix)
- APCu configuration (key prefix)
- Connection test button
- Environment variables documentation
- Environment variable configuration for Docker/containerized environments:
- `WP_PROMETHEUS_STORAGE_ADAPTER` - Select storage adapter
- `WP_PROMETHEUS_REDIS_HOST` - Redis server hostname
- `WP_PROMETHEUS_REDIS_PORT` - Redis server port
- `WP_PROMETHEUS_REDIS_PASSWORD` - Redis authentication
- `WP_PROMETHEUS_REDIS_DATABASE` - Redis database index (0-15)
- `WP_PROMETHEUS_REDIS_PREFIX` - Redis key prefix
- `WP_PROMETHEUS_APCU_PREFIX` - APCu key prefix
- Automatic fallback to In-Memory storage if configured adapter fails
- Docker Compose example in admin settings
### Changed
- Settings page now has 6 tabs: License, Metrics, Storage, Custom Metrics, Dashboards, Help
- Updated translations with all new strings (English and German)
- Collector now uses StorageFactory for storage adapter instantiation
## [0.3.0] - 2026-02-02
### Added

184
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
- Dedicated plugin settings under 'Settings/Metrics' menu
- 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
### 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.
*No planned features at this time.*
*No pending roadmap items.*
## Technical Stack
@@ -78,11 +79,7 @@ Text domain: `wp-prometheus`
- `en_US` - English (United States) [base language - .pot template]
- `de_CH` - German (Switzerland, formal)
To compile translations to .mo files for production:
```bash
for po in languages/*.po; do msgfmt -o "${po%.po}.mo" "$po"; done
```
Translation compilation (.po → .mo) is handled automatically by CI/CD pipeline during release builds. No local compilation needed.
### Create releases
@@ -234,7 +231,8 @@ wp-prometheus/
│ ├── Metrics/
│ │ ├── Collector.php # Prometheus metrics collector
│ │ ├── CustomMetricBuilder.php # Custom metric CRUD
│ │ ── RuntimeCollector.php # Runtime metrics collector
│ │ ── RuntimeCollector.php # Runtime metrics collector
│ │ └── StorageFactory.php # Storage adapter factory
│ ├── Installer.php # Activation/Deactivation
│ ├── Plugin.php # Main plugin class
│ └── index.php
@@ -290,6 +288,178 @@ add_action( 'wp_prometheus_collect_metrics', function( $collector ) {
## 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)
- Added option to disable early mode for users who need extensibility
- Implementation:
- Added `wp_prometheus_disable_early_mode` WordPress option
- Added `WP_PROMETHEUS_DISABLE_EARLY_MODE` environment variable support
- Option check in `wp_prometheus_early_metrics_check()` before early interception
- Environment variable accepts `1`, `true`, `yes`, `on` (case-insensitive)
- Admin UI in Metrics tab:
- "Early Mode" section with description of functionality
- Checkbox to disable early metrics interception
- Environment override notice when env var is set
- Current status indicator showing early mode state
- **Key Learning**: Balancing compatibility vs extensibility
- Early mode fixes memory issues but disables `wp_prometheus_collect_metrics` hook
- Users with custom metrics need the hook, so early mode must be optional
- Default remains enabled (safe) with explicit opt-out for advanced users
### 2026-02-02 - Plugin Compatibility Fix (v0.4.1)
- Fixed memory exhaustion (1GB limit) when wp-fedistream (Twig-based) plugin is active
- Root cause: Infinite recursion through WordPress hook system when content filters trigger Twig rendering
- Solution: Early metrics endpoint interception before full WordPress initialization
- Implementation changes:
- Added `wp_prometheus_early_metrics_check()` in bootstrap file (wp-prometheus.php)
- Checks REQUEST_URI for `/metrics` pattern before `plugins_loaded` fires
- Defines `WP_PROMETHEUS_EARLY_METRICS` constant to signal early mode
- Removes content filters (`the_content`, `the_excerpt`, `get_the_excerpt`, `the_title`)
- Collector skips `wp_prometheus_collect_metrics` action in early mode
- Changed MetricsEndpoint from `template_redirect` to `parse_request` hook
- **Key Learning**: WordPress plugin loading order and hook timing
- Plugins load alphabetically, so wp-fedistream ('f') loads before wp-prometheus ('p')
- `template_redirect` fires too late - after themes and Twig initialize
- `parse_request` fires earlier but still after plugin files load
- Earliest interception point: top-level code in plugin bootstrap file
- **Key Learning**: Content filter recursion in WordPress
- `get_the_excerpt()` internally triggers `apply_filters('the_content', ...)`
- This creates unexpected recursion vectors when Twig templates process content
- Solution: Remove all content-related filters before metrics collection
- **Key Learning**: Isolating metrics collection from WordPress template system
- Use `remove_all_filters()` to clear problematic filter chains
- Skip extensibility hooks (`do_action`) when in isolated early mode
- Exit immediately after output to prevent further WordPress processing
### 2026-02-02 - Persistent Storage (v0.4.0)
- Added persistent storage support for metrics:
- `StorageFactory.php` - Factory class for storage adapter instantiation
- Redis storage adapter for shared metrics across multiple instances
- APCu storage adapter for single-server high-performance caching
- Automatic fallback to In-Memory if configured adapter fails
- Added new "Storage" tab in admin settings:
- Storage adapter selection (In-Memory, Redis, APCu)
- Redis configuration (host, port, password, database, key prefix)
- APCu configuration (key prefix)
- Connection test button with detailed error messages
- Added environment variable support for Docker deployments:
- `WP_PROMETHEUS_STORAGE_ADAPTER` - Adapter selection
- `WP_PROMETHEUS_REDIS_HOST`, `_PORT`, `_PASSWORD`, `_DATABASE`, `_PREFIX`
- `WP_PROMETHEUS_APCU_PREFIX`
- Environment variables take precedence over admin settings
- Updated `Collector.php` to use `StorageFactory::get_adapter()`
- Updated Help tab with storage backends documentation
- Updated translation files with all new strings
- **Key Learning**: promphp/prometheus_client_php storage adapters
- Redis adapter requires options array with host, port, password, timeout
- APCu adapter just needs a prefix string
- Use `Redis::setPrefix()` before instantiation for custom key prefixes
- **Key Learning**: Docker environment variable configuration
- Use `getenv()` with explicit false check (`false !== getenv()`)
- Environment variables should override WordPress options for containerized deployments
### 2026-02-02 - Custom Metrics & Dashboards (v0.3.0)
- Added Custom Metric Builder with full admin UI:

61
PLAN.md
View File

@@ -59,6 +59,7 @@ wp-prometheus/
│ └── release.yml # CI/CD pipeline
├── assets/
│ ├── css/ # Admin/Frontend styles
│ ├── dashboards/ # Grafana dashboard templates
│ └── js/
│ └── admin.js # Admin JavaScript
├── languages/ # Translation files
@@ -67,13 +68,17 @@ wp-prometheus/
├── releases/ # Release packages
├── src/
│ ├── Admin/
│ │ ├── DashboardProvider.php
│ │ └── Settings.php
│ ├── Endpoint/
│ │ └── MetricsEndpoint.php
│ ├── License/
│ │ └── Manager.php
│ ├── Metrics/
│ │ ── Collector.php
│ │ ── Collector.php
│ │ ├── CustomMetricBuilder.php
│ │ ├── RuntimeCollector.php
│ │ └── StorageFactory.php
│ ├── Installer.php
│ ├── Plugin.php
│ └── index.php
@@ -159,13 +164,57 @@ Alternatively, the token can be passed as a query parameter (for testing):
https://example.com/metrics/?token=your-auth-token
```
## Storage Configuration
The plugin supports multiple storage backends for Prometheus metrics:
### Available Adapters
| Adapter | Description | Use Case |
| --------- | ------------------------------- | ------------------------------------- |
| In-Memory | Default, no persistence | Development, single request metrics |
| Redis | Shared storage across instances | Production, load-balanced environments|
| APCu | High-performance local cache | Production, single-server deployments |
### Environment Variables
For Docker or containerized environments, configure storage via environment variables:
```bash
# Storage adapter selection
WP_PROMETHEUS_STORAGE_ADAPTER=redis
# Redis configuration
WP_PROMETHEUS_REDIS_HOST=redis
WP_PROMETHEUS_REDIS_PORT=6379
WP_PROMETHEUS_REDIS_PASSWORD=secret
WP_PROMETHEUS_REDIS_DATABASE=0
WP_PROMETHEUS_REDIS_PREFIX=WORDPRESS_PROMETHEUS_
# APCu configuration
WP_PROMETHEUS_APCU_PREFIX=wp_prom
```
### Docker Compose Example
```yaml
services:
wordpress:
image: wordpress:latest
environment:
WP_PROMETHEUS_STORAGE_ADAPTER: redis
WP_PROMETHEUS_REDIS_HOST: redis
WP_PROMETHEUS_REDIS_PORT: 6379
depends_on:
- redis
redis:
image: redis:alpine
```
## Future Enhancements
### Version 0.3.0
- Custom metric builder in admin
- Metric export/import
- Grafana dashboard templates
*No planned features at this time.*
## Dependencies

View File

@@ -6,7 +6,13 @@ A WordPress plugin that provides a Prometheus-compatible `/metrics` endpoint wit
- Prometheus-compatible authenticated `/metrics` endpoint
- 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
- Dashboard extension hook for third-party Grafana dashboards
- Settings page under Settings > Metrics
- Bearer token authentication
- 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.
#### 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+)
| Metric | Type | Labels | Description |
@@ -154,6 +175,51 @@ $histogram = $collector->register_histogram( $name, $help, $labels, $buckets );
$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
### Build for Release

View File

@@ -9,6 +9,61 @@
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 */
.wp-prometheus-license-status {
margin: 15px 0;
@@ -161,6 +216,32 @@
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 {
border-radius: 4px;

View File

@@ -946,6 +946,95 @@
],
"title": "Average Query Duration (Overall)",
"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",

View File

@@ -21,6 +21,9 @@
// Runtime metrics reset handler.
initResetRuntimeHandler();
// Storage tab handlers.
initStorageHandlers();
});
/**
@@ -613,4 +616,131 @@
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
/**
* Initialize storage tab handlers.
*/
function initStorageHandlers() {
var $form = $('#wp-prometheus-storage-form');
var $adapterSelect = $('#storage-adapter');
// Show/hide adapter-specific config.
$adapterSelect.on('change', function() {
var adapter = $(this).val();
$('#redis-config').toggle(adapter === 'redis');
$('#apcu-config').toggle(adapter === 'apcu');
});
// Save storage settings.
$form.on('submit', function(e) {
e.preventDefault();
saveStorageSettings();
});
// Test storage connection.
$('#test-storage').on('click', function() {
testStorageConnection();
});
}
/**
* Save storage settings via AJAX.
*/
function saveStorageSettings() {
var $spinner = $('#wp-prometheus-storage-spinner');
var $message = $('#wp-prometheus-storage-message');
var $form = $('#wp-prometheus-storage-form');
$spinner.addClass('is-active');
$message.hide();
var formData = $form.serialize();
formData += '&action=wp_prometheus_save_storage';
formData += '&nonce=' + wpPrometheus.storageNonce;
$.ajax({
url: wpPrometheus.ajaxUrl,
type: 'POST',
data: formData,
success: function(response) {
$spinner.removeClass('is-active');
if (response.success) {
var noticeClass = response.data.warning ? 'notice-warning' : 'notice-success';
$message
.removeClass('notice-error notice-success notice-warning')
.addClass('notice ' + noticeClass)
.html('<p>' + response.data.message + '</p>')
.show();
if (!response.data.warning) {
setTimeout(function() {
location.reload();
}, 1500);
}
} else {
$message
.removeClass('notice-success notice-warning')
.addClass('notice notice-error')
.html('<p>' + (response.data.message || 'An error occurred.') + '</p>')
.show();
}
},
error: function() {
$spinner.removeClass('is-active');
$message
.removeClass('notice-success notice-warning')
.addClass('notice notice-error')
.html('<p>Connection error. Please try again.</p>')
.show();
}
});
}
/**
* Test storage connection via AJAX.
*/
function testStorageConnection() {
var $spinner = $('#wp-prometheus-storage-spinner');
var $message = $('#wp-prometheus-storage-message');
var $form = $('#wp-prometheus-storage-form');
$spinner.addClass('is-active');
$message.hide();
var formData = $form.serialize();
formData += '&action=wp_prometheus_test_storage';
formData += '&nonce=' + wpPrometheus.storageNonce;
$.ajax({
url: wpPrometheus.ajaxUrl,
type: 'POST',
data: formData,
success: function(response) {
$spinner.removeClass('is-active');
if (response.success) {
$message
.removeClass('notice-error notice-warning')
.addClass('notice notice-success')
.html('<p>' + response.data.message + '</p>')
.show();
} else {
$message
.removeClass('notice-success notice-warning')
.addClass('notice notice-error')
.html('<p>' + (response.data.message || 'Connection test failed.') + '</p>')
.show();
}
},
error: function() {
$spinner.removeClass('is-active');
$message
.removeClass('notice-success notice-warning')
.addClass('notice notice-error')
.html('<p>Connection error. Please try again.</p>')
.show();
}
});
}
})(jQuery);

Binary file not shown.

View File

@@ -3,7 +3,7 @@
# This file is distributed under the GPL v2 or later.
msgid ""
msgstr ""
"Project-Id-Version: WP Prometheus 0.3.0\n"
"Project-Id-Version: WP Prometheus 0.4.2\n"
"Report-Msgid-Bugs-To: https://src.bundespruefstelle.ch/magdev/wp-prometheus/issues\n"
"POT-Creation-Date: 2026-02-02T00:00:00+00:00\n"
"PO-Revision-Date: 2026-02-02T00:00:00+00:00\n"
@@ -317,6 +317,10 @@ msgstr "HTTP-Anfragedauer-Verteilung"
msgid "Database queries by endpoint"
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
msgid "Scheduled cron events by hook"
msgstr "Geplante Cron-Ereignisse nach Hook"
@@ -617,3 +621,379 @@ msgstr "WP Prometheus erfordert installierte Composer-Abhaengigkeiten. Bitte fue
#: wp-prometheus.php
msgid "WP Prometheus requires PHP version %s or higher."
msgstr "WP Prometheus erfordert PHP-Version %s oder hoeher."
#: src/Admin/Settings.php
msgid "Storage"
msgstr "Speicher"
#: src/Admin/Settings.php
msgid "Metrics Storage Configuration"
msgstr "Metriken-Speicherkonfiguration"
#: src/Admin/Settings.php
msgid "Configure how Prometheus metrics are stored. Persistent storage (Redis, APCu) allows metrics to survive between requests and aggregate data over time."
msgstr "Konfigurieren Sie, wie Prometheus-Metriken gespeichert werden. Persistenter Speicher (Redis, APCu) ermoeglicht es, Metriken zwischen Anfragen zu erhalten und Daten ueber Zeit zu aggregieren."
#: src/Admin/Settings.php
msgid "Environment Override Active"
msgstr "Umgebungsvariablen-Ueberschreibung aktiv"
#: src/Admin/Settings.php
msgid "Storage adapter is configured via environment variable. Admin settings will be ignored."
msgstr "Speicher-Adapter ist ueber Umgebungsvariable konfiguriert. Admin-Einstellungen werden ignoriert."
#: src/Admin/Settings.php
msgid "Storage Fallback Active"
msgstr "Speicher-Fallback aktiv"
#: src/Admin/Settings.php
msgid "Falling back to In-Memory storage."
msgstr "Faellt zurueck auf In-Memory-Speicher."
#: src/Admin/Settings.php
msgid "Current Status:"
msgstr "Aktueller Status:"
#. translators: %s: Active adapter name
#: src/Admin/Settings.php
msgid "Using %s storage."
msgstr "Verwende %s-Speicher."
#: src/Admin/Settings.php
msgid "Storage Adapter"
msgstr "Speicher-Adapter"
#: src/Admin/Settings.php
msgid "unavailable"
msgstr "nicht verfuegbar"
#: src/Admin/Settings.php
msgid "Select the storage backend for metrics. Redis and APCu require their respective PHP extensions."
msgstr "Waehlen Sie das Speicher-Backend fuer Metriken. Redis und APCu erfordern ihre jeweiligen PHP-Erweiterungen."
#: src/Admin/Settings.php
msgid "Redis Configuration"
msgstr "Redis-Konfiguration"
#: src/Admin/Settings.php
msgid "Host"
msgstr "Host"
#. translators: %s: Environment variable name
#: src/Admin/Settings.php
msgid "Can be overridden with %s environment variable."
msgstr "Kann mit Umgebungsvariable %s ueberschrieben werden."
#: src/Admin/Settings.php
msgid "Port"
msgstr "Port"
#: src/Admin/Settings.php
msgid "Password"
msgstr "Passwort"
#: src/Admin/Settings.php
msgid "Leave empty if not required"
msgstr "Leer lassen, falls nicht erforderlich"
#: src/Admin/Settings.php
msgid "Database"
msgstr "Datenbank"
#. translators: %s: Environment variable name
#: src/Admin/Settings.php
msgid "Redis database index (0-15). Can be overridden with %s."
msgstr "Redis-Datenbankindex (0-15). Kann mit %s ueberschrieben werden."
#: src/Admin/Settings.php
msgid "Key Prefix"
msgstr "Schluessel-Praefix"
#: src/Admin/Settings.php
msgid "Prefix for Redis keys. Useful when sharing Redis with other applications."
msgstr "Praefix fuer Redis-Schluessel. Nuetzlich bei gemeinsamer Redis-Nutzung mit anderen Anwendungen."
#: src/Admin/Settings.php
msgid "APCu Configuration"
msgstr "APCu-Konfiguration"
#. translators: %s: Environment variable name
#: src/Admin/Settings.php
msgid "Prefix for APCu keys. Can be overridden with %s."
msgstr "Praefix fuer APCu-Schluessel. Kann mit %s ueberschrieben werden."
#: src/Admin/Settings.php
msgid "Save Storage Settings"
msgstr "Speicher-Einstellungen speichern"
#: src/Admin/Settings.php
msgid "Test Connection"
msgstr "Verbindung testen"
#: src/Admin/Settings.php
msgid "Environment Variables"
msgstr "Umgebungsvariablen"
#: src/Admin/Settings.php
msgid "For Docker or containerized environments, you can configure storage using environment variables. These take precedence over admin settings."
msgstr "Fuer Docker- oder Container-Umgebungen koennen Sie den Speicher ueber Umgebungsvariablen konfigurieren. Diese haben Vorrang vor Admin-Einstellungen."
#: src/Admin/Settings.php
msgid "Variable"
msgstr "Variable"
#: src/Admin/Settings.php
msgid "Example"
msgstr "Beispiel"
#: src/Admin/Settings.php
msgid "Storage adapter to use"
msgstr "Zu verwendender Speicher-Adapter"
#: src/Admin/Settings.php
msgid "Redis server hostname"
msgstr "Redis-Server-Hostname"
#: src/Admin/Settings.php
msgid "Redis server port"
msgstr "Redis-Server-Port"
#: src/Admin/Settings.php
msgid "Redis authentication password"
msgstr "Redis-Authentifizierungspasswort"
#: src/Admin/Settings.php
msgid "Redis database index"
msgstr "Redis-Datenbankindex"
#: src/Admin/Settings.php
msgid "Redis key prefix"
msgstr "Redis-Schluessel-Praefix"
#: src/Admin/Settings.php
msgid "APCu key prefix"
msgstr "APCu-Schluessel-Praefix"
#: src/Admin/Settings.php
msgid "Docker Compose Example"
msgstr "Docker Compose-Beispiel"
#: src/Admin/Settings.php
msgid "Permission denied."
msgstr "Zugriff verweigert."
#: src/Admin/Settings.php
msgid "Storage adapter is configured via environment variable and cannot be changed."
msgstr "Speicher-Adapter ist ueber Umgebungsvariable konfiguriert und kann nicht geaendert werden."
#: src/Admin/Settings.php
msgid "Invalid storage adapter."
msgstr "Ungueltiger Speicher-Adapter."
#: src/Admin/Settings.php
msgid "Storage settings saved successfully."
msgstr "Speicher-Einstellungen erfolgreich gespeichert."
#: src/Admin/Settings.php
msgid "Storage settings saved, but connection test failed:"
msgstr "Speicher-Einstellungen gespeichert, aber Verbindungstest fehlgeschlagen:"
#: src/Metrics/StorageFactory.php
msgid "In-Memory (default, no persistence)"
msgstr "In-Memory (Standard, keine Persistenz)"
#: src/Metrics/StorageFactory.php
msgid "Redis (requires PHP Redis extension)"
msgstr "Redis (erfordert PHP-Redis-Erweiterung)"
#: src/Metrics/StorageFactory.php
msgid "APCu (requires APCu extension)"
msgstr "APCu (erfordert APCu-Erweiterung)"
#: src/Metrics/StorageFactory.php
msgid "PHP Redis extension is not installed."
msgstr "PHP-Redis-Erweiterung ist nicht installiert."
#. translators: %s: Error message
#: src/Metrics/StorageFactory.php
msgid "Redis connection failed: %s"
msgstr "Redis-Verbindung fehlgeschlagen: %s"
#. translators: %s: Error message
#: src/Metrics/StorageFactory.php
msgid "Redis error: %s"
msgstr "Redis-Fehler: %s"
#. translators: %s: Error message
#: src/Metrics/StorageFactory.php
msgid "Storage error: %s"
msgstr "Speicherfehler: %s"
#: src/Metrics/StorageFactory.php
msgid "APCu extension is not installed."
msgstr "APCu-Erweiterung ist nicht installiert."
#: src/Metrics/StorageFactory.php
msgid "APCu is installed but not enabled."
msgstr "APCu ist installiert, aber nicht aktiviert."
#. translators: %s: Error message
#: src/Metrics/StorageFactory.php
msgid "APCu error: %s"
msgstr "APCu-Fehler: %s"
#: src/Metrics/StorageFactory.php
msgid "In-Memory storage is always available."
msgstr "In-Memory-Speicher ist immer verfuegbar."
#: src/Metrics/StorageFactory.php
msgid "Unknown storage adapter."
msgstr "Unbekannter Speicher-Adapter."
#: src/Metrics/StorageFactory.php
msgid "Could not connect to Redis server."
msgstr "Verbindung zum Redis-Server konnte nicht hergestellt werden."
#: src/Metrics/StorageFactory.php
msgid "Redis authentication failed."
msgstr "Redis-Authentifizierung fehlgeschlagen."
#. translators: %s: Redis host:port
#: src/Metrics/StorageFactory.php
msgid "Successfully connected to Redis at %s."
msgstr "Erfolgreich mit Redis verbunden unter %s."
#: src/Metrics/StorageFactory.php
msgid "Redis ping failed."
msgstr "Redis-Ping fehlgeschlagen."
#: src/Metrics/StorageFactory.php
msgid "APCu is installed but not enabled. Check your php.ini settings."
msgstr "APCu ist installiert, aber nicht aktiviert. Pruefen Sie Ihre php.ini-Einstellungen."
#: src/Metrics/StorageFactory.php
msgid "APCu store operation failed."
msgstr "APCu-Speicheroperation fehlgeschlagen."
#. translators: %s: Memory info
#: src/Metrics/StorageFactory.php
msgid "APCu is working. Memory: %s used."
msgstr "APCu funktioniert. Speicher: %s belegt."
#: src/Metrics/StorageFactory.php
msgid "APCu fetch operation returned unexpected value."
msgstr "APCu-Abrufoperation hat unerwarteten Wert zurueckgegeben."
#: src/Admin/Settings.php
msgid "Early Mode"
msgstr "Fruehzeitiger Modus"
#: src/Admin/Settings.php
msgid "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."
msgstr "Der fruehzeitige Modus faengt /metrics-Anfragen vor der vollstaendigen WordPress-Initialisierung ab. Dies verhindert Speichererschoepfungsprobleme, die durch einige Plugins verursacht werden (z.B. Twig-basierte Themes/Plugins), deaktiviert jedoch den wp_prometheus_collect_metrics-Hook fuer benutzerdefinierte Metriken."
#: src/Admin/Settings.php
msgid "Early mode is configured via WP_PROMETHEUS_DISABLE_EARLY_MODE environment variable. Admin settings will be ignored."
msgstr "Der fruehzeitige Modus ist ueber die Umgebungsvariable WP_PROMETHEUS_DISABLE_EARLY_MODE konfiguriert. Admin-Einstellungen werden ignoriert."
#: src/Admin/Settings.php
msgid "Disable Early Mode"
msgstr "Fruehzeitigen Modus deaktivieren"
#: src/Admin/Settings.php
msgid "Disable early metrics interception"
msgstr "Fruehzeitige Metriken-Abfangung deaktivieren"
#: src/Admin/Settings.php
msgid "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."
msgstr "Wenn deaktiviert, werden Metriken ueber das normale WordPress-Template-Laden erfasst. Dies aktiviert den wp_prometheus_collect_metrics-Hook fuer benutzerdefinierte Metriken, kann jedoch Probleme mit einigen Plugins verursachen."
#: src/Admin/Settings.php
msgid "Early mode is active (this request was served via early interception)"
msgstr "Fruehzeitiger Modus ist aktiv (diese Anfrage wurde ueber fruehzeitige Abfangung verarbeitet)"
#: src/Admin/Settings.php
msgid "Early mode is disabled"
msgstr "Fruehzeitiger Modus ist deaktiviert"
#: src/Admin/Settings.php
msgid "Early mode is enabled (active for /metrics requests)"
msgstr "Fruehzeitiger Modus ist aktiviert (aktiv fuer /metrics-Anfragen)"
#: src/Admin/Settings.php
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."
#: 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

@@ -2,7 +2,7 @@
# This file is distributed under the GPL v2 or later.
msgid ""
msgstr ""
"Project-Id-Version: WP Prometheus 0.3.0\n"
"Project-Id-Version: WP Prometheus 0.4.2\n"
"Report-Msgid-Bugs-To: https://src.bundespruefstelle.ch/magdev/wp-prometheus/issues\n"
"POT-Creation-Date: 2026-02-02T00:00:00+00:00\n"
"MIME-Version: 1.0\n"
@@ -314,6 +314,10 @@ msgstr ""
msgid "Database queries by endpoint"
msgstr ""
#: src/Admin/Settings.php
msgid "Database query duration distribution (requires SAVEQUERIES)"
msgstr ""
#: src/Admin/Settings.php
msgid "Scheduled cron events by hook"
msgstr ""
@@ -614,3 +618,379 @@ msgstr ""
#: wp-prometheus.php
msgid "WP Prometheus requires PHP version %s or higher."
msgstr ""
#: src/Admin/Settings.php
msgid "Storage"
msgstr ""
#: src/Admin/Settings.php
msgid "Metrics Storage Configuration"
msgstr ""
#: src/Admin/Settings.php
msgid "Configure how Prometheus metrics are stored. Persistent storage (Redis, APCu) allows metrics to survive between requests and aggregate data over time."
msgstr ""
#: src/Admin/Settings.php
msgid "Environment Override Active"
msgstr ""
#: src/Admin/Settings.php
msgid "Storage adapter is configured via environment variable. Admin settings will be ignored."
msgstr ""
#: src/Admin/Settings.php
msgid "Storage Fallback Active"
msgstr ""
#: src/Admin/Settings.php
msgid "Falling back to In-Memory storage."
msgstr ""
#: src/Admin/Settings.php
msgid "Current Status:"
msgstr ""
#. translators: %s: Active adapter name
#: src/Admin/Settings.php
msgid "Using %s storage."
msgstr ""
#: src/Admin/Settings.php
msgid "Storage Adapter"
msgstr ""
#: src/Admin/Settings.php
msgid "unavailable"
msgstr ""
#: src/Admin/Settings.php
msgid "Select the storage backend for metrics. Redis and APCu require their respective PHP extensions."
msgstr ""
#: src/Admin/Settings.php
msgid "Redis Configuration"
msgstr ""
#: src/Admin/Settings.php
msgid "Host"
msgstr ""
#. translators: %s: Environment variable name
#: src/Admin/Settings.php
msgid "Can be overridden with %s environment variable."
msgstr ""
#: src/Admin/Settings.php
msgid "Port"
msgstr ""
#: src/Admin/Settings.php
msgid "Password"
msgstr ""
#: src/Admin/Settings.php
msgid "Leave empty if not required"
msgstr ""
#: src/Admin/Settings.php
msgid "Database"
msgstr ""
#. translators: %s: Environment variable name
#: src/Admin/Settings.php
msgid "Redis database index (0-15). Can be overridden with %s."
msgstr ""
#: src/Admin/Settings.php
msgid "Key Prefix"
msgstr ""
#: src/Admin/Settings.php
msgid "Prefix for Redis keys. Useful when sharing Redis with other applications."
msgstr ""
#: src/Admin/Settings.php
msgid "APCu Configuration"
msgstr ""
#. translators: %s: Environment variable name
#: src/Admin/Settings.php
msgid "Prefix for APCu keys. Can be overridden with %s."
msgstr ""
#: src/Admin/Settings.php
msgid "Save Storage Settings"
msgstr ""
#: src/Admin/Settings.php
msgid "Test Connection"
msgstr ""
#: src/Admin/Settings.php
msgid "Environment Variables"
msgstr ""
#: src/Admin/Settings.php
msgid "For Docker or containerized environments, you can configure storage using environment variables. These take precedence over admin settings."
msgstr ""
#: src/Admin/Settings.php
msgid "Variable"
msgstr ""
#: src/Admin/Settings.php
msgid "Example"
msgstr ""
#: src/Admin/Settings.php
msgid "Storage adapter to use"
msgstr ""
#: src/Admin/Settings.php
msgid "Redis server hostname"
msgstr ""
#: src/Admin/Settings.php
msgid "Redis server port"
msgstr ""
#: src/Admin/Settings.php
msgid "Redis authentication password"
msgstr ""
#: src/Admin/Settings.php
msgid "Redis database index"
msgstr ""
#: src/Admin/Settings.php
msgid "Redis key prefix"
msgstr ""
#: src/Admin/Settings.php
msgid "APCu key prefix"
msgstr ""
#: src/Admin/Settings.php
msgid "Docker Compose Example"
msgstr ""
#: src/Admin/Settings.php
msgid "Permission denied."
msgstr ""
#: src/Admin/Settings.php
msgid "Storage adapter is configured via environment variable and cannot be changed."
msgstr ""
#: src/Admin/Settings.php
msgid "Invalid storage adapter."
msgstr ""
#: src/Admin/Settings.php
msgid "Storage settings saved successfully."
msgstr ""
#: src/Admin/Settings.php
msgid "Storage settings saved, but connection test failed:"
msgstr ""
#: src/Metrics/StorageFactory.php
msgid "In-Memory (default, no persistence)"
msgstr ""
#: src/Metrics/StorageFactory.php
msgid "Redis (requires PHP Redis extension)"
msgstr ""
#: src/Metrics/StorageFactory.php
msgid "APCu (requires APCu extension)"
msgstr ""
#: src/Metrics/StorageFactory.php
msgid "PHP Redis extension is not installed."
msgstr ""
#. translators: %s: Error message
#: src/Metrics/StorageFactory.php
msgid "Redis connection failed: %s"
msgstr ""
#. translators: %s: Error message
#: src/Metrics/StorageFactory.php
msgid "Redis error: %s"
msgstr ""
#. translators: %s: Error message
#: src/Metrics/StorageFactory.php
msgid "Storage error: %s"
msgstr ""
#: src/Metrics/StorageFactory.php
msgid "APCu extension is not installed."
msgstr ""
#: src/Metrics/StorageFactory.php
msgid "APCu is installed but not enabled."
msgstr ""
#. translators: %s: Error message
#: src/Metrics/StorageFactory.php
msgid "APCu error: %s"
msgstr ""
#: src/Metrics/StorageFactory.php
msgid "In-Memory storage is always available."
msgstr ""
#: src/Metrics/StorageFactory.php
msgid "Unknown storage adapter."
msgstr ""
#: src/Metrics/StorageFactory.php
msgid "Could not connect to Redis server."
msgstr ""
#: src/Metrics/StorageFactory.php
msgid "Redis authentication failed."
msgstr ""
#. translators: %s: Redis host:port
#: src/Metrics/StorageFactory.php
msgid "Successfully connected to Redis at %s."
msgstr ""
#: src/Metrics/StorageFactory.php
msgid "Redis ping failed."
msgstr ""
#: src/Metrics/StorageFactory.php
msgid "APCu is installed but not enabled. Check your php.ini settings."
msgstr ""
#: src/Metrics/StorageFactory.php
msgid "APCu store operation failed."
msgstr ""
#. translators: %s: Memory info
#: src/Metrics/StorageFactory.php
msgid "APCu is working. Memory: %s used."
msgstr ""
#: src/Metrics/StorageFactory.php
msgid "APCu fetch operation returned unexpected value."
msgstr ""
#: src/Admin/Settings.php
msgid "Early Mode"
msgstr ""
#: src/Admin/Settings.php
msgid "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."
msgstr ""
#: src/Admin/Settings.php
msgid "Early mode is configured via WP_PROMETHEUS_DISABLE_EARLY_MODE environment variable. Admin settings will be ignored."
msgstr ""
#: src/Admin/Settings.php
msgid "Disable Early Mode"
msgstr ""
#: src/Admin/Settings.php
msgid "Disable early metrics interception"
msgstr ""
#: src/Admin/Settings.php
msgid "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."
msgstr ""
#: src/Admin/Settings.php
msgid "Early mode is active (this request was served via early interception)"
msgstr ""
#: src/Admin/Settings.php
msgid "Early mode is disabled"
msgstr ""
#: src/Admin/Settings.php
msgid "Early mode is enabled (active for /metrics requests)"
msgstr ""
#: src/Admin/Settings.php
msgid "Clear all accumulated runtime metric data (HTTP requests, database queries). This is useful for testing or starting fresh."
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.
*
* Provides Grafana dashboard templates for download.
* Supports both built-in dashboards and third-party registrations.
*/
class DashboardProvider {
/**
* Dashboard directory path.
* Dashboard directory path for built-in dashboards.
*
* @var string
*/
private string $dashboard_dir;
/**
* Available dashboard definitions.
* Built-in dashboard definitions.
*
* @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.
@@ -39,43 +54,241 @@ class DashboardProvider {
public function __construct() {
$this->dashboard_dir = WP_PROMETHEUS_PATH . 'assets/dashboards/';
$this->dashboards = array(
$this->builtin_dashboards = array(
'wordpress-overview' => array(
'title' => __( 'WordPress Overview', 'wp-prometheus' ),
'description' => __( 'General WordPress metrics including users, posts, comments, and plugins.', 'wp-prometheus' ),
'file' => 'wordpress-overview.json',
'icon' => 'dashicons-wordpress',
'source' => 'builtin',
),
'wordpress-runtime' => array(
'title' => __( 'Runtime Performance', 'wp-prometheus' ),
'description' => __( 'HTTP request metrics, database query performance, and response times.', 'wp-prometheus' ),
'file' => 'wordpress-runtime.json',
'icon' => 'dashicons-performance',
'source' => 'builtin',
),
'wordpress-woocommerce' => array(
'title' => __( 'WooCommerce Store', 'wp-prometheus' ),
'description' => __( 'WooCommerce metrics including products, orders, revenue, and customers.', 'wp-prometheus' ),
'file' => 'wordpress-woocommerce.json',
'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.
*
* @return array
*/
public function get_available(): array {
// Fire registration hook first (only once).
$this->fire_registration_hook();
$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'];
if ( file_exists( $file_path ) ) {
$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;
}
@@ -86,35 +299,70 @@ class DashboardProvider {
* @return string|null JSON content or null if not found.
*/
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 );
if ( ! isset( $this->dashboards[ $slug ] ) ) {
return null;
// Check built-in dashboards first.
if ( isset( $this->builtin_dashboards[ $slug ] ) ) {
$dashboard = $this->builtin_dashboards[ $slug ];
$file_path = $this->dashboard_dir . $dashboard['file'];
// Security: Ensure file is within dashboard directory.
$real_path = realpath( $file_path );
$real_dir = realpath( $this->dashboard_dir );
if ( false === $real_path || false === $real_dir ||
strpos( $real_path, $real_dir ) !== 0 ) {
return null;
}
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;
}
$file_path = $this->dashboard_dir . $this->dashboards[ $slug ]['file'];
// Check registered dashboards.
if ( isset( $this->registered_dashboards[ $slug ] ) ) {
$dashboard = $this->registered_dashboards[ $slug ];
// Security: Ensure file is within dashboard directory.
$real_path = realpath( $file_path );
$real_dir = realpath( $this->dashboard_dir );
// Inline JSON.
if ( ! empty( $dashboard['json'] ) ) {
return $dashboard['json'];
}
if ( false === $real_path || false === $real_dir || strpos( $real_path, $real_dir ) !== 0 ) {
return null;
// 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;
}
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;
}
}
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 );
if ( false === $content ) {
return null;
}
return $content;
return null;
}
/**
@@ -124,13 +372,20 @@ class DashboardProvider {
* @return array|null Dashboard metadata or null if not found.
*/
public function get_metadata( string $slug ): ?array {
// Fire registration hook first.
$this->fire_registration_hook();
$slug = sanitize_file_name( $slug );
if ( ! isset( $this->dashboards[ $slug ] ) ) {
return null;
if ( isset( $this->builtin_dashboards[ $slug ] ) ) {
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.
*/
public function get_filename( string $slug ): ?string {
// Fire registration hook first.
$this->fire_registration_hook();
$slug = sanitize_file_name( $slug );
if ( ! isset( $this->dashboards[ $slug ] ) ) {
return null;
// Built-in dashboards have predefined filenames.
if ( isset( $this->builtin_dashboards[ $slug ] ) ) {
return $this->builtin_dashboards[ $slug ]['file'];
}
return $this->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;
}
/**
* 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

@@ -10,6 +10,7 @@ namespace Magdev\WpPrometheus\Admin;
use Magdev\WpPrometheus\License\Manager as LicenseManager;
use Magdev\WpPrometheus\Metrics\CustomMetricBuilder;
use Magdev\WpPrometheus\Metrics\RuntimeCollector;
use Magdev\WpPrometheus\Metrics\StorageFactory;
// Prevent direct file access.
if ( ! defined( 'ABSPATH' ) ) {
@@ -51,6 +52,7 @@ class Settings {
$this->tabs = array(
'license' => __( 'License', 'wp-prometheus' ),
'metrics' => __( 'Metrics', 'wp-prometheus' ),
'storage' => __( 'Storage', 'wp-prometheus' ),
'custom' => __( 'Custom Metrics', 'wp-prometheus' ),
'dashboards' => __( 'Dashboards', 'wp-prometheus' ),
'help' => __( 'Help', 'wp-prometheus' ),
@@ -70,6 +72,8 @@ class Settings {
add_action( 'wp_ajax_wp_prometheus_import_metrics', array( $this, 'ajax_import_metrics' ) );
add_action( 'wp_ajax_wp_prometheus_download_dashboard', array( $this, 'ajax_download_dashboard' ) );
add_action( 'wp_ajax_wp_prometheus_reset_runtime_metrics', array( $this, 'ajax_reset_runtime_metrics' ) );
add_action( 'wp_ajax_wp_prometheus_save_storage', array( $this, 'ajax_save_storage' ) );
add_action( 'wp_ajax_wp_prometheus_test_storage', array( $this, 'ajax_test_storage' ) );
}
/**
@@ -103,17 +107,25 @@ class Settings {
* @return void
*/
public function register_settings(): void {
// Register settings for metrics tab.
register_setting( 'wp_prometheus_metrics_settings', 'wp_prometheus_auth_token', array(
// Register settings for endpoint sub-tab.
register_setting( 'wp_prometheus_endpoint_settings', 'wp_prometheus_auth_token', array(
'type' => 'string',
'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',
'sanitize_callback' => array( $this, 'sanitize_metrics' ),
) );
// Register settings for advanced sub-tab.
register_setting( 'wp_prometheus_advanced_settings', 'wp_prometheus_isolated_mode', array(
'type' => 'boolean',
'sanitize_callback' => 'rest_sanitize_boolean',
'default' => false,
) );
// Auth token section.
add_settings_section(
'wp_prometheus_auth_section',
@@ -183,6 +195,7 @@ class Settings {
'importNonce' => wp_create_nonce( 'wp_prometheus_import' ),
'dashboardNonce' => wp_create_nonce( 'wp_prometheus_dashboard' ),
'resetRuntimeNonce' => wp_create_nonce( 'wp_prometheus_reset_runtime' ),
'storageNonce' => wp_create_nonce( 'wp_prometheus_storage' ),
'confirmDelete' => __( 'Are you sure you want to delete this metric?', 'wp-prometheus' ),
'confirmReset' => __( 'Are you sure you want to reset all runtime metrics? This cannot be undone.', 'wp-prometheus' ),
'confirmRegenerateToken' => __( 'Are you sure you want to regenerate the auth token? You will need to update your Prometheus configuration.', 'wp-prometheus' ),
@@ -226,6 +239,9 @@ class Settings {
case 'metrics':
$this->render_metrics_tab();
break;
case 'storage':
$this->render_storage_tab();
break;
case 'custom':
$this->render_custom_metrics_tab();
break;
@@ -386,32 +402,537 @@ class Settings {
<?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.
*
* @return void
*/
private function render_metrics_tab(): void {
$subtabs = $this->get_metrics_subtabs();
$current_subtab = $this->get_current_metrics_subtab();
?>
<div class="wp-prometheus-subtabs">
<ul class="wp-prometheus-subtab-nav">
<?php foreach ( $subtabs as $subtab_id => $subtab_name ) : ?>
<?php
$subtab_url = add_query_arg(
array(
'page' => 'wp-prometheus',
'tab' => 'metrics',
'subtab' => $subtab_id,
),
admin_url( 'options-general.php' )
);
$active_class = ( $current_subtab === $subtab_id ) ? ' active' : '';
?>
<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>
<div class="wp-prometheus-subtab-content">
<?php
switch ( $current_subtab ) {
case 'endpoint':
$this->render_metrics_endpoint_subtab();
break;
case 'selection':
$this->render_metrics_selection_subtab();
break;
case 'runtime':
$this->render_metrics_runtime_subtab();
break;
case 'advanced':
$this->render_metrics_advanced_subtab();
break;
}
?>
</div>
</div>
<?php
}
/**
* Render metrics endpoint sub-tab.
*
* @return void
*/
private function render_metrics_endpoint_subtab(): void {
?>
<form method="post" action="options.php">
<?php
settings_fields( 'wp_prometheus_metrics_settings' );
do_settings_sections( 'wp-prometheus-metrics' );
submit_button();
?>
<?php settings_fields( 'wp_prometheus_endpoint_settings' ); ?>
<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
}
<hr style="margin: 30px 0;">
/**
* 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( 'Reset Runtime Metrics', 'wp-prometheus' ); ?></h3>
<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>
<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>
</p>
<div id="wp-prometheus-reset-message" style="display: none; margin-top: 10px;"></div>
<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>
<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 ) : ?>
<div class="notice notice-warning inline" style="padding: 12px; margin: 15px 0;">
<strong><?php esc_html_e( 'Environment Override Active', 'wp-prometheus' ); ?></strong>
<p><?php esc_html_e( 'Mode is configured via WP_PROMETHEUS_ISOLATED_MODE environment variable. Admin settings will be ignored.', 'wp-prometheus' ); ?></p>
</div>
<?php endif; ?>
<table class="form-table" role="presentation">
<tr>
<th scope="row"><?php esc_html_e( 'Isolated Mode', 'wp-prometheus' ); ?></th>
<td>
<label>
<input type="checkbox" name="wp_prometheus_isolated_mode" value="1"
<?php checked( $isolated_mode ); ?>
<?php disabled( $env_override ); ?>>
<?php esc_html_e( 'Enable isolated mode', 'wp-prometheus' ); ?>
</label>
<p class="description">
<?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>
</td>
</tr>
<tr>
<th scope="row"><?php esc_html_e( 'Current Status', 'wp-prometheus' ); ?></th>
<td>
<?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>
<?php esc_html_e( 'Safe mode active - custom hooks enabled with filter protection', 'wp-prometheus' ); ?>
<?php elseif ( $isolated_mode ) : ?>
<span class="dashicons dashicons-lock" style="color: orange;"></span>
<?php esc_html_e( 'Isolated mode enabled (active for /metrics requests)', 'wp-prometheus' ); ?>
<?php else : ?>
<span class="dashicons dashicons-yes-alt" style="color: green;"></span>
<?php esc_html_e( 'Safe mode enabled (default) - custom hooks with filter protection', 'wp-prometheus' ); ?>
<?php endif; ?>
</td>
</tr>
</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
}
/**
* Render storage tab content.
*
* @return void
*/
private function render_storage_tab(): void {
$configured_adapter = StorageFactory::get_configured_adapter();
$active_adapter = StorageFactory::get_active_adapter();
$last_error = StorageFactory::get_last_error();
$redis_config = StorageFactory::get_redis_config();
$apcu_prefix = StorageFactory::get_apcu_prefix();
$adapters = StorageFactory::get_available_adapters();
// Check environment variable overrides.
$env_override = false !== getenv( 'WP_PROMETHEUS_STORAGE_ADAPTER' );
?>
<div class="wp-prometheus-storage">
<h2><?php esc_html_e( 'Metrics Storage Configuration', 'wp-prometheus' ); ?></h2>
<p class="description">
<?php esc_html_e( 'Configure how Prometheus metrics are stored. Persistent storage (Redis, APCu) allows metrics to survive between requests and aggregate data over time.', 'wp-prometheus' ); ?>
</p>
<?php if ( $env_override ) : ?>
<div class="notice notice-info" style="padding: 12px; margin: 15px 0;">
<strong><?php esc_html_e( 'Environment Override Active', 'wp-prometheus' ); ?></strong>
<p><?php esc_html_e( 'Storage adapter is configured via environment variable. Admin settings will be ignored.', 'wp-prometheus' ); ?></p>
</div>
<?php endif; ?>
<?php if ( ! empty( $last_error ) ) : ?>
<div class="notice notice-warning" style="padding: 12px; margin: 15px 0;">
<strong><?php esc_html_e( 'Storage Fallback Active', 'wp-prometheus' ); ?></strong>
<p><?php echo esc_html( $last_error ); ?></p>
<p><?php esc_html_e( 'Falling back to In-Memory storage.', 'wp-prometheus' ); ?></p>
</div>
<?php endif; ?>
<div class="notice notice-<?php echo $active_adapter === $configured_adapter && empty( $last_error ) ? 'success' : 'warning'; ?>" style="padding: 12px; margin: 15px 0;">
<strong><?php esc_html_e( 'Current Status:', 'wp-prometheus' ); ?></strong>
<?php
printf(
/* translators: %s: Active adapter name */
esc_html__( 'Using %s storage.', 'wp-prometheus' ),
'<code>' . esc_html( ucfirst( $active_adapter ) ) . '</code>'
);
?>
</div>
<form id="wp-prometheus-storage-form">
<table class="form-table" role="presentation">
<tr>
<th scope="row">
<label for="storage-adapter"><?php esc_html_e( 'Storage Adapter', 'wp-prometheus' ); ?></label>
</th>
<td>
<select name="adapter" id="storage-adapter" <?php disabled( $env_override ); ?>>
<?php foreach ( $adapters as $key => $label ) : ?>
<option value="<?php echo esc_attr( $key ); ?>" <?php selected( $configured_adapter, $key ); ?>>
<?php echo esc_html( $label ); ?>
<?php if ( ! StorageFactory::is_adapter_available( $key ) && 'inmemory' !== $key ) : ?>
(<?php esc_html_e( 'unavailable', 'wp-prometheus' ); ?>)
<?php endif; ?>
</option>
<?php endforeach; ?>
</select>
<p class="description">
<?php esc_html_e( 'Select the storage backend for metrics. Redis and APCu require their respective PHP extensions.', 'wp-prometheus' ); ?>
</p>
</td>
</tr>
</table>
<div id="redis-config" style="<?php echo 'redis' === $configured_adapter ? '' : 'display: none;'; ?>">
<h3><?php esc_html_e( 'Redis Configuration', 'wp-prometheus' ); ?></h3>
<table class="form-table" role="presentation">
<tr>
<th scope="row">
<label for="redis-host"><?php esc_html_e( 'Host', 'wp-prometheus' ); ?></label>
</th>
<td>
<input type="text" name="redis_host" id="redis-host" class="regular-text"
value="<?php echo esc_attr( $redis_config['host'] ); ?>"
placeholder="127.0.0.1">
<p class="description">
<?php
printf(
/* translators: %s: Environment variable name */
esc_html__( 'Can be overridden with %s environment variable.', 'wp-prometheus' ),
'<code>WP_PROMETHEUS_REDIS_HOST</code>'
);
?>
</p>
</td>
</tr>
<tr>
<th scope="row">
<label for="redis-port"><?php esc_html_e( 'Port', 'wp-prometheus' ); ?></label>
</th>
<td>
<input type="number" name="redis_port" id="redis-port" class="small-text"
value="<?php echo esc_attr( $redis_config['port'] ); ?>"
placeholder="6379" min="1" max="65535">
<p class="description">
<?php
printf(
/* translators: %s: Environment variable name */
esc_html__( 'Can be overridden with %s environment variable.', 'wp-prometheus' ),
'<code>WP_PROMETHEUS_REDIS_PORT</code>'
);
?>
</p>
</td>
</tr>
<tr>
<th scope="row">
<label for="redis-password"><?php esc_html_e( 'Password', 'wp-prometheus' ); ?></label>
</th>
<td>
<input type="password" name="redis_password" id="redis-password" class="regular-text"
value="<?php echo esc_attr( $redis_config['password'] ); ?>"
placeholder="<?php esc_attr_e( 'Leave empty if not required', 'wp-prometheus' ); ?>">
<p class="description">
<?php
printf(
/* translators: %s: Environment variable name */
esc_html__( 'Can be overridden with %s environment variable.', 'wp-prometheus' ),
'<code>WP_PROMETHEUS_REDIS_PASSWORD</code>'
);
?>
</p>
</td>
</tr>
<tr>
<th scope="row">
<label for="redis-database"><?php esc_html_e( 'Database', 'wp-prometheus' ); ?></label>
</th>
<td>
<input type="number" name="redis_database" id="redis-database" class="small-text"
value="<?php echo esc_attr( $redis_config['database'] ); ?>"
placeholder="0" min="0" max="15">
<p class="description">
<?php
printf(
/* translators: %s: Environment variable name */
esc_html__( 'Redis database index (0-15). Can be overridden with %s.', 'wp-prometheus' ),
'<code>WP_PROMETHEUS_REDIS_DATABASE</code>'
);
?>
</p>
</td>
</tr>
<tr>
<th scope="row">
<label for="redis-prefix"><?php esc_html_e( 'Key Prefix', 'wp-prometheus' ); ?></label>
</th>
<td>
<input type="text" name="redis_prefix" id="redis-prefix" class="regular-text"
value="<?php echo esc_attr( $redis_config['prefix'] ); ?>"
placeholder="WORDPRESS_PROMETHEUS_">
<p class="description">
<?php esc_html_e( 'Prefix for Redis keys. Useful when sharing Redis with other applications.', 'wp-prometheus' ); ?>
</p>
</td>
</tr>
</table>
</div>
<div id="apcu-config" style="<?php echo 'apcu' === $configured_adapter ? '' : 'display: none;'; ?>">
<h3><?php esc_html_e( 'APCu Configuration', 'wp-prometheus' ); ?></h3>
<table class="form-table" role="presentation">
<tr>
<th scope="row">
<label for="apcu-prefix"><?php esc_html_e( 'Key Prefix', 'wp-prometheus' ); ?></label>
</th>
<td>
<input type="text" name="apcu_prefix" id="apcu-prefix" class="regular-text"
value="<?php echo esc_attr( $apcu_prefix ); ?>"
placeholder="wp_prom">
<p class="description">
<?php
printf(
/* translators: %s: Environment variable name */
esc_html__( 'Prefix for APCu keys. Can be overridden with %s.', 'wp-prometheus' ),
'<code>WP_PROMETHEUS_APCU_PREFIX</code>'
);
?>
</p>
</td>
</tr>
</table>
</div>
<p class="submit">
<button type="submit" class="button button-primary" <?php disabled( $env_override ); ?>>
<?php esc_html_e( 'Save Storage Settings', 'wp-prometheus' ); ?>
</button>
<button type="button" id="test-storage" class="button button-secondary">
<?php esc_html_e( 'Test Connection', 'wp-prometheus' ); ?>
</button>
<span id="wp-prometheus-storage-spinner" class="spinner" style="float: none;"></span>
</p>
</form>
<div id="wp-prometheus-storage-message" style="display: none; margin-top: 10px;"></div>
<hr style="margin: 30px 0;">
<h3><?php esc_html_e( 'Environment Variables', 'wp-prometheus' ); ?></h3>
<p class="description">
<?php esc_html_e( 'For Docker or containerized environments, you can configure storage using environment variables. These take precedence over admin settings.', 'wp-prometheus' ); ?>
</p>
<table class="widefat striped" style="margin: 15px 0; max-width: 800px;">
<thead>
<tr>
<th><?php esc_html_e( 'Variable', 'wp-prometheus' ); ?></th>
<th><?php esc_html_e( 'Description', 'wp-prometheus' ); ?></th>
<th><?php esc_html_e( 'Example', 'wp-prometheus' ); ?></th>
</tr>
</thead>
<tbody>
<tr>
<td><code>WP_PROMETHEUS_STORAGE_ADAPTER</code></td>
<td><?php esc_html_e( 'Storage adapter to use', 'wp-prometheus' ); ?></td>
<td><code>redis</code></td>
</tr>
<tr>
<td><code>WP_PROMETHEUS_REDIS_HOST</code></td>
<td><?php esc_html_e( 'Redis server hostname', 'wp-prometheus' ); ?></td>
<td><code>redis</code></td>
</tr>
<tr>
<td><code>WP_PROMETHEUS_REDIS_PORT</code></td>
<td><?php esc_html_e( 'Redis server port', 'wp-prometheus' ); ?></td>
<td><code>6379</code></td>
</tr>
<tr>
<td><code>WP_PROMETHEUS_REDIS_PASSWORD</code></td>
<td><?php esc_html_e( 'Redis authentication password', 'wp-prometheus' ); ?></td>
<td><code>secret123</code></td>
</tr>
<tr>
<td><code>WP_PROMETHEUS_REDIS_DATABASE</code></td>
<td><?php esc_html_e( 'Redis database index', 'wp-prometheus' ); ?></td>
<td><code>0</code></td>
</tr>
<tr>
<td><code>WP_PROMETHEUS_REDIS_PREFIX</code></td>
<td><?php esc_html_e( 'Redis key prefix', 'wp-prometheus' ); ?></td>
<td><code>MYSITE_PROM_</code></td>
</tr>
<tr>
<td><code>WP_PROMETHEUS_APCU_PREFIX</code></td>
<td><?php esc_html_e( 'APCu key prefix', 'wp-prometheus' ); ?></td>
<td><code>wp_prom</code></td>
</tr>
</tbody>
</table>
<h4><?php esc_html_e( 'Docker Compose Example', 'wp-prometheus' ); ?></h4>
<pre style="background: #f1f1f1; padding: 15px; overflow-x: auto; margin: 15px 0;">services:
wordpress:
image: wordpress:latest
environment:
WP_PROMETHEUS_STORAGE_ADAPTER: redis
WP_PROMETHEUS_REDIS_HOST: redis
WP_PROMETHEUS_REDIS_PORT: 6379
depends_on:
- redis
redis:
image: redis:alpine</pre>
</div>
<?php
}
@@ -693,20 +1214,45 @@ class Settings {
<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>
<div class="wp-prometheus-dashboard-grid">
<?php foreach ( $dashboards as $slug => $dashboard ) : ?>
<div class="wp-prometheus-dashboard-card">
<div class="dashboard-icon">
<span class="dashicons <?php echo esc_attr( $dashboard['icon'] ); ?>"></span>
<?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">
<?php
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">
<span class="dashicons <?php echo esc_attr( $dashboard['icon'] ); ?>"></span>
</div>
<h3><?php echo esc_html( $dashboard['title'] ); ?></h3>
<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 ); ?>">
<?php esc_html_e( 'Download', 'wp-prometheus' ); ?>
</button>
</div>
<h3><?php echo esc_html( $dashboard['title'] ); ?></h3>
<p><?php echo esc_html( $dashboard['description'] ); ?></p>
<button type="button" class="button button-primary download-dashboard" data-slug="<?php echo esc_attr( $slug ); ?>">
<?php esc_html_e( 'Download', 'wp-prometheus' ); ?>
</button>
</div>
<?php endforeach; ?>
</div>
<?php endforeach; ?>
</div>
<?php endif; ?>
<hr style="margin: 30px 0;">
@@ -819,6 +1365,11 @@ class Settings {
<td><?php esc_html_e( 'Counter', 'wp-prometheus' ); ?></td>
<td><?php esc_html_e( 'Database queries by endpoint', 'wp-prometheus' ); ?></td>
</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>
<td><code>wordpress_cron_events_total</code></td>
<td><?php esc_html_e( 'Gauge', 'wp-prometheus' ); ?></td>
@@ -872,6 +1423,38 @@ class Settings {
);
$gauge->set( 42, array( 'value1', 'value2' ) );
} );</pre>
<h3><?php esc_html_e( 'Storage Backends', 'wp-prometheus' ); ?></h3>
<p><?php esc_html_e( 'The plugin supports multiple storage backends for metrics persistence:', 'wp-prometheus' ); ?></p>
<table class="widefat striped" style="margin: 15px 0;">
<thead>
<tr>
<th><?php esc_html_e( 'Adapter', 'wp-prometheus' ); ?></th>
<th><?php esc_html_e( 'Description', 'wp-prometheus' ); ?></th>
<th><?php esc_html_e( 'Use Case', 'wp-prometheus' ); ?></th>
</tr>
</thead>
<tbody>
<tr>
<td><strong>In-Memory</strong></td>
<td><?php esc_html_e( 'Default storage, no persistence between requests', 'wp-prometheus' ); ?></td>
<td><?php esc_html_e( 'Development, testing', 'wp-prometheus' ); ?></td>
</tr>
<tr>
<td><strong>Redis</strong></td>
<td><?php esc_html_e( 'Shared storage, survives restarts', 'wp-prometheus' ); ?></td>
<td><?php esc_html_e( 'Production, load-balanced environments', 'wp-prometheus' ); ?></td>
</tr>
<tr>
<td><strong>APCu</strong></td>
<td><?php esc_html_e( 'Fast local cache, process-specific', 'wp-prometheus' ); ?></td>
<td><?php esc_html_e( 'Production, single-server deployments', 'wp-prometheus' ); ?></td>
</tr>
</tbody>
</table>
<p class="description">
<?php esc_html_e( 'Configure storage in the Storage tab. For Docker environments, use environment variables like WP_PROMETHEUS_STORAGE_ADAPTER.', 'wp-prometheus' ); ?>
</p>
<?php
}
@@ -1210,4 +1793,102 @@ class Settings {
wp_send_json_success( array( 'message' => __( 'Runtime metrics have been reset.', 'wp-prometheus' ) ) );
}
/**
* AJAX handler for saving storage settings.
*
* @return void
*/
public function ajax_save_storage(): void {
check_ajax_referer( 'wp_prometheus_storage', 'nonce' );
if ( ! current_user_can( 'manage_options' ) ) {
wp_send_json_error( array( 'message' => __( 'Permission denied.', 'wp-prometheus' ) ) );
}
// Check if environment variable override is active.
if ( false !== getenv( 'WP_PROMETHEUS_STORAGE_ADAPTER' ) ) {
wp_send_json_error( array( 'message' => __( 'Storage adapter is configured via environment variable and cannot be changed.', 'wp-prometheus' ) ) );
}
$adapter = isset( $_POST['adapter'] ) ? sanitize_key( $_POST['adapter'] ) : 'inmemory';
// Validate adapter.
$valid_adapters = array_keys( StorageFactory::get_available_adapters() );
if ( ! in_array( $adapter, $valid_adapters, true ) ) {
wp_send_json_error( array( 'message' => __( 'Invalid storage adapter.', 'wp-prometheus' ) ) );
}
// Build config array.
$config = array(
'adapter' => $adapter,
);
// Redis config.
if ( 'redis' === $adapter ) {
$config['redis'] = array(
'host' => isset( $_POST['redis_host'] ) ? sanitize_text_field( wp_unslash( $_POST['redis_host'] ) ) : '127.0.0.1',
'port' => isset( $_POST['redis_port'] ) ? absint( $_POST['redis_port'] ) : 6379,
'password' => isset( $_POST['redis_password'] ) ? sanitize_text_field( wp_unslash( $_POST['redis_password'] ) ) : '',
'database' => isset( $_POST['redis_database'] ) ? absint( $_POST['redis_database'] ) : 0,
'prefix' => isset( $_POST['redis_prefix'] ) ? sanitize_key( $_POST['redis_prefix'] ) : 'WORDPRESS_PROMETHEUS_',
);
}
// APCu config.
if ( 'apcu' === $adapter ) {
$config['apcu_prefix'] = isset( $_POST['apcu_prefix'] ) ? sanitize_key( $_POST['apcu_prefix'] ) : 'wp_prom';
}
// Save configuration.
StorageFactory::save_config( $config );
// Test if the new configuration works.
$test_result = StorageFactory::test_connection( $adapter, $config['redis'] ?? array() );
if ( $test_result['success'] ) {
wp_send_json_success( array(
'message' => __( 'Storage settings saved successfully.', 'wp-prometheus' ) . ' ' . $test_result['message'],
) );
} else {
wp_send_json_success( array(
'message' => __( 'Storage settings saved, but connection test failed:', 'wp-prometheus' ) . ' ' . $test_result['message'],
'warning' => true,
) );
}
}
/**
* AJAX handler for testing storage connection.
*
* @return void
*/
public function ajax_test_storage(): void {
check_ajax_referer( 'wp_prometheus_storage', 'nonce' );
if ( ! current_user_can( 'manage_options' ) ) {
wp_send_json_error( array( 'message' => __( 'Permission denied.', 'wp-prometheus' ) ) );
}
$adapter = isset( $_POST['adapter'] ) ? sanitize_key( $_POST['adapter'] ) : 'inmemory';
// Build test config from form data.
$config = array();
if ( 'redis' === $adapter ) {
$config = array(
'host' => isset( $_POST['redis_host'] ) ? sanitize_text_field( wp_unslash( $_POST['redis_host'] ) ) : '127.0.0.1',
'port' => isset( $_POST['redis_port'] ) ? absint( $_POST['redis_port'] ) : 6379,
'password' => isset( $_POST['redis_password'] ) ? sanitize_text_field( wp_unslash( $_POST['redis_password'] ) ) : '',
'database' => isset( $_POST['redis_database'] ) ? absint( $_POST['redis_database'] ) : 0,
);
}
$result = StorageFactory::test_connection( $adapter, $config );
if ( $result['success'] ) {
wp_send_json_success( array( 'message' => $result['message'] ) );
} else {
wp_send_json_error( array( 'message' => $result['message'] ) );
}
}
}

View File

@@ -45,7 +45,9 @@ class MetricsEndpoint {
*/
private function init_hooks(): void {
add_action( 'init', array( $this, 'register_endpoint' ) );
add_action( 'template_redirect', array( $this, 'handle_request' ) );
// Use parse_request instead of template_redirect to handle the request early,
// before themes and other plugins (like Twig-based ones) can interfere.
add_action( 'parse_request', array( $this, 'handle_request' ) );
}
/**
@@ -66,10 +68,13 @@ class MetricsEndpoint {
/**
* Handle the metrics endpoint request.
*
* Called during parse_request to intercept before themes/plugins load.
*
* @param \WP $wp WordPress environment instance.
* @return void
*/
public function handle_request(): void {
if ( ! get_query_var( 'wp_prometheus_metrics' ) ) {
public function handle_request( \WP $wp ): void {
if ( empty( $wp->query_vars['wp_prometheus_metrics'] ) ) {
return;
}

View File

@@ -8,9 +8,9 @@
namespace Magdev\WpPrometheus\Metrics;
use Prometheus\CollectorRegistry;
use Prometheus\Storage\InMemory;
use Prometheus\RenderTextFormat;
use Magdev\WpPrometheus\Metrics\CustomMetricBuilder;
use Magdev\WpPrometheus\Metrics\StorageFactory;
// Prevent direct file access.
if ( ! defined( 'ABSPATH' ) ) {
@@ -42,7 +42,7 @@ class Collector {
* Constructor.
*/
public function __construct() {
$this->registry = new CollectorRegistry( new InMemory() );
$this->registry = new CollectorRegistry( StorageFactory::get_adapter() );
}
/**
@@ -121,9 +121,62 @@ class Collector {
/**
* Fires after default metrics are collected.
*
* In isolated mode, skip custom hooks to avoid any potential issues.
* In safe mode (default), fire hooks with protection against recursion.
*
* @param Collector $collector The metrics collector instance.
*/
do_action( 'wp_prometheus_collect_metrics', $this );
if ( defined( 'WP_PROMETHEUS_ISOLATED_MODE' ) && WP_PROMETHEUS_ISOLATED_MODE ) {
// 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

@@ -0,0 +1,502 @@
<?php
/**
* Storage factory for Prometheus metrics.
*
* @package WP_Prometheus
*/
namespace Magdev\WpPrometheus\Metrics;
use Prometheus\Storage\Adapter;
use Prometheus\Storage\InMemory;
use Prometheus\Storage\Redis;
use Prometheus\Storage\APC;
use Prometheus\Exception\StorageException;
// Prevent direct file access.
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* StorageFactory class.
*
* Creates and configures storage adapters for Prometheus metrics.
* Supports configuration via WordPress options or environment variables.
*/
class StorageFactory {
/**
* Available storage adapters.
*/
public const ADAPTER_INMEMORY = 'inmemory';
public const ADAPTER_REDIS = 'redis';
public const ADAPTER_APCU = 'apcu';
/**
* Environment variable names.
*/
private const ENV_STORAGE_ADAPTER = 'WP_PROMETHEUS_STORAGE_ADAPTER';
private const ENV_REDIS_HOST = 'WP_PROMETHEUS_REDIS_HOST';
private const ENV_REDIS_PORT = 'WP_PROMETHEUS_REDIS_PORT';
private const ENV_REDIS_PASSWORD = 'WP_PROMETHEUS_REDIS_PASSWORD';
private const ENV_REDIS_DATABASE = 'WP_PROMETHEUS_REDIS_DATABASE';
private const ENV_REDIS_PREFIX = 'WP_PROMETHEUS_REDIS_PREFIX';
private const ENV_APCU_PREFIX = 'WP_PROMETHEUS_APCU_PREFIX';
/**
* Default Redis prefix.
*/
private const DEFAULT_REDIS_PREFIX = 'WORDPRESS_PROMETHEUS_';
/**
* Default APCu prefix.
*/
private const DEFAULT_APCU_PREFIX = 'wp_prom';
/**
* Singleton instance of the storage adapter.
*
* @var Adapter|null
*/
private static ?Adapter $instance = null;
/**
* Last error message.
*
* @var string
*/
private static string $last_error = '';
/**
* Get the storage adapter instance.
*
* @return Adapter
*/
public static function get_adapter(): Adapter {
if ( null === self::$instance ) {
self::$instance = self::create_adapter();
}
return self::$instance;
}
/**
* Reset the singleton instance (useful for testing or config changes).
*
* @return void
*/
public static function reset(): void {
self::$instance = null;
self::$last_error = '';
}
/**
* Get the last error message.
*
* @return string
*/
public static function get_last_error(): string {
return self::$last_error;
}
/**
* Get available storage adapters.
*
* @return array<string, string>
*/
public static function get_available_adapters(): array {
return array(
self::ADAPTER_INMEMORY => __( 'In-Memory (default, no persistence)', 'wp-prometheus' ),
self::ADAPTER_REDIS => __( 'Redis (requires PHP Redis extension)', 'wp-prometheus' ),
self::ADAPTER_APCU => __( 'APCu (requires APCu extension)', 'wp-prometheus' ),
);
}
/**
* Check if a storage adapter is available on this system.
*
* @param string $adapter Adapter name.
* @return bool
*/
public static function is_adapter_available( string $adapter ): bool {
switch ( $adapter ) {
case self::ADAPTER_INMEMORY:
return true;
case self::ADAPTER_REDIS:
return extension_loaded( 'redis' );
case self::ADAPTER_APCU:
return extension_loaded( 'apcu' ) && apcu_enabled();
default:
return false;
}
}
/**
* Get the configured storage adapter name.
*
* @return string
*/
public static function get_configured_adapter(): string {
// Check environment variable first.
$env_adapter = getenv( self::ENV_STORAGE_ADAPTER );
if ( false !== $env_adapter && ! empty( $env_adapter ) ) {
return strtolower( $env_adapter );
}
// Fall back to WordPress option.
return get_option( 'wp_prometheus_storage_adapter', self::ADAPTER_INMEMORY );
}
/**
* Get the active storage adapter name (may differ from configured if fallback occurred).
*
* @return string
*/
public static function get_active_adapter(): string {
// Ensure adapter is created.
self::get_adapter();
$configured = self::get_configured_adapter();
if ( self::is_adapter_available( $configured ) && empty( self::$last_error ) ) {
return $configured;
}
return self::ADAPTER_INMEMORY;
}
/**
* Create the storage adapter based on configuration.
*
* @return Adapter
*/
private static function create_adapter(): Adapter {
$adapter = self::get_configured_adapter();
self::$last_error = '';
switch ( $adapter ) {
case self::ADAPTER_REDIS:
return self::create_redis_adapter();
case self::ADAPTER_APCU:
return self::create_apcu_adapter();
case self::ADAPTER_INMEMORY:
default:
return new InMemory();
}
}
/**
* Create Redis storage adapter.
*
* @return Adapter
*/
private static function create_redis_adapter(): Adapter {
if ( ! extension_loaded( 'redis' ) ) {
self::$last_error = __( 'PHP Redis extension is not installed.', 'wp-prometheus' );
return new InMemory();
}
$config = self::get_redis_config();
try {
Redis::setPrefix( $config['prefix'] );
$redis = new Redis( array(
'host' => $config['host'],
'port' => $config['port'],
'password' => $config['password'] ?: null,
'timeout' => 0.5,
'read_timeout' => 10,
'persistent_connections' => true,
) );
// Test connection by triggering initialization.
// The Redis adapter connects lazily, so we need to check it works.
return $redis;
} catch ( StorageException $e ) {
self::$last_error = sprintf(
/* translators: %s: Error message */
__( 'Redis connection failed: %s', 'wp-prometheus' ),
$e->getMessage()
);
return new InMemory();
} catch ( \RedisException $e ) {
self::$last_error = sprintf(
/* translators: %s: Error message */
__( 'Redis error: %s', 'wp-prometheus' ),
$e->getMessage()
);
return new InMemory();
} catch ( \Exception $e ) {
self::$last_error = sprintf(
/* translators: %s: Error message */
__( 'Storage error: %s', 'wp-prometheus' ),
$e->getMessage()
);
return new InMemory();
}
}
/**
* Create APCu storage adapter.
*
* @return Adapter
*/
private static function create_apcu_adapter(): Adapter {
if ( ! extension_loaded( 'apcu' ) ) {
self::$last_error = __( 'APCu extension is not installed.', 'wp-prometheus' );
return new InMemory();
}
if ( ! apcu_enabled() ) {
self::$last_error = __( 'APCu is installed but not enabled.', 'wp-prometheus' );
return new InMemory();
}
$prefix = self::get_apcu_prefix();
try {
return new APC( $prefix );
} catch ( StorageException $e ) {
self::$last_error = sprintf(
/* translators: %s: Error message */
__( 'APCu error: %s', 'wp-prometheus' ),
$e->getMessage()
);
return new InMemory();
}
}
/**
* Get Redis configuration.
*
* @return array{host: string, port: int, password: string, database: int, prefix: string}
*/
public static function get_redis_config(): array {
// Check environment variables first.
$env_host = getenv( self::ENV_REDIS_HOST );
$env_port = getenv( self::ENV_REDIS_PORT );
$env_password = getenv( self::ENV_REDIS_PASSWORD );
$env_database = getenv( self::ENV_REDIS_DATABASE );
$env_prefix = getenv( self::ENV_REDIS_PREFIX );
// Get WordPress options as fallback.
$options = get_option( 'wp_prometheus_redis_config', array() );
return array(
'host' => ( false !== $env_host && ! empty( $env_host ) ) ? $env_host : ( $options['host'] ?? '127.0.0.1' ),
'port' => ( false !== $env_port && ! empty( $env_port ) ) ? (int) $env_port : ( (int) ( $options['port'] ?? 6379 ) ),
'password' => ( false !== $env_password ) ? $env_password : ( $options['password'] ?? '' ),
'database' => ( false !== $env_database && ! empty( $env_database ) ) ? (int) $env_database : ( (int) ( $options['database'] ?? 0 ) ),
'prefix' => ( false !== $env_prefix && ! empty( $env_prefix ) ) ? $env_prefix : ( $options['prefix'] ?? self::DEFAULT_REDIS_PREFIX ),
);
}
/**
* Get APCu prefix.
*
* @return string
*/
public static function get_apcu_prefix(): string {
$env_prefix = getenv( self::ENV_APCU_PREFIX );
if ( false !== $env_prefix && ! empty( $env_prefix ) ) {
return $env_prefix;
}
return get_option( 'wp_prometheus_apcu_prefix', self::DEFAULT_APCU_PREFIX );
}
/**
* Save storage configuration.
*
* @param array $config Configuration array.
* @return void
*/
public static function save_config( array $config ): void {
if ( isset( $config['adapter'] ) ) {
update_option( 'wp_prometheus_storage_adapter', sanitize_key( $config['adapter'] ) );
}
if ( isset( $config['redis'] ) && is_array( $config['redis'] ) ) {
$redis_config = array(
'host' => sanitize_text_field( $config['redis']['host'] ?? '127.0.0.1' ),
'port' => absint( $config['redis']['port'] ?? 6379 ),
'password' => sanitize_text_field( $config['redis']['password'] ?? '' ),
'database' => absint( $config['redis']['database'] ?? 0 ),
'prefix' => sanitize_key( $config['redis']['prefix'] ?? self::DEFAULT_REDIS_PREFIX ),
);
update_option( 'wp_prometheus_redis_config', $redis_config );
}
if ( isset( $config['apcu_prefix'] ) ) {
update_option( 'wp_prometheus_apcu_prefix', sanitize_key( $config['apcu_prefix'] ) );
}
// Reset the singleton to apply new configuration.
self::reset();
}
/**
* Test storage adapter connection.
*
* @param string $adapter Adapter name.
* @param array $config Optional configuration to test.
* @return array{success: bool, message: string}
*/
public static function test_connection( string $adapter, array $config = array() ): array {
switch ( $adapter ) {
case self::ADAPTER_REDIS:
return self::test_redis_connection( $config );
case self::ADAPTER_APCU:
return self::test_apcu_connection( $config );
case self::ADAPTER_INMEMORY:
return array(
'success' => true,
'message' => __( 'In-Memory storage is always available.', 'wp-prometheus' ),
);
default:
return array(
'success' => false,
'message' => __( 'Unknown storage adapter.', 'wp-prometheus' ),
);
}
}
/**
* Test Redis connection.
*
* @param array $config Redis configuration.
* @return array{success: bool, message: string}
*/
private static function test_redis_connection( array $config ): array {
if ( ! extension_loaded( 'redis' ) ) {
return array(
'success' => false,
'message' => __( 'PHP Redis extension is not installed.', 'wp-prometheus' ),
);
}
$redis_config = ! empty( $config ) ? $config : self::get_redis_config();
try {
$redis = new \Redis();
$connected = $redis->connect(
$redis_config['host'],
$redis_config['port'],
0.5 // timeout
);
if ( ! $connected ) {
return array(
'success' => false,
'message' => __( 'Could not connect to Redis server.', 'wp-prometheus' ),
);
}
if ( ! empty( $redis_config['password'] ) ) {
$authenticated = $redis->auth( $redis_config['password'] );
if ( ! $authenticated ) {
return array(
'success' => false,
'message' => __( 'Redis authentication failed.', 'wp-prometheus' ),
);
}
}
if ( $redis_config['database'] > 0 ) {
$redis->select( $redis_config['database'] );
}
// Test with a ping.
$pong = $redis->ping();
$redis->close();
if ( $pong ) {
return array(
'success' => true,
'message' => sprintf(
/* translators: %s: Redis host:port */
__( 'Successfully connected to Redis at %s.', 'wp-prometheus' ),
$redis_config['host'] . ':' . $redis_config['port']
),
);
}
return array(
'success' => false,
'message' => __( 'Redis ping failed.', 'wp-prometheus' ),
);
} catch ( \RedisException $e ) {
return array(
'success' => false,
'message' => sprintf(
/* translators: %s: Error message */
__( 'Redis error: %s', 'wp-prometheus' ),
$e->getMessage()
),
);
}
}
/**
* Test APCu connection.
*
* @param array $config APCu configuration.
* @return array{success: bool, message: string}
*/
private static function test_apcu_connection( array $config ): array {
if ( ! extension_loaded( 'apcu' ) ) {
return array(
'success' => false,
'message' => __( 'APCu extension is not installed.', 'wp-prometheus' ),
);
}
if ( ! apcu_enabled() ) {
return array(
'success' => false,
'message' => __( 'APCu is installed but not enabled. Check your php.ini settings.', 'wp-prometheus' ),
);
}
// Test with a simple store/fetch.
$test_key = 'wp_prometheus_test_' . time();
$test_value = 'test_' . wp_rand();
$stored = apcu_store( $test_key, $test_value, 5 );
if ( ! $stored ) {
return array(
'success' => false,
'message' => __( 'APCu store operation failed.', 'wp-prometheus' ),
);
}
$fetched = apcu_fetch( $test_key );
apcu_delete( $test_key );
if ( $fetched === $test_value ) {
$info = apcu_cache_info( true );
return array(
'success' => true,
'message' => sprintf(
/* translators: %s: Memory info */
__( 'APCu is working. Memory: %s used.', 'wp-prometheus' ),
size_format( $info['mem_size'] ?? 0 )
),
);
}
return array(
'success' => false,
'message' => __( 'APCu fetch operation returned unexpected value.', 'wp-prometheus' ),
);
}
}

View File

@@ -3,7 +3,7 @@
* Plugin Name: WP Prometheus
* Plugin URI: https://src.bundespruefstelle.ch/magdev/wp-prometheus
* Description: Prometheus metrics endpoint for WordPress with extensible hooks for custom metrics.
* Version: 0.3.0
* Version: 0.4.7
* Requires at least: 6.4
* Requires PHP: 8.3
* Author: Marco Graetsch
@@ -21,12 +21,155 @@ if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* Early metrics request detection.
*
* Detects /metrics requests early and removes problematic content filters
* to prevent recursion issues with Twig-based plugins. Unlike the previous
* "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 {
// Only handle /metrics requests.
$request_uri = isset( $_SERVER['REQUEST_URI'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REQUEST_URI'] ) ) : '';
$path = wp_parse_url( $request_uri, PHP_URL_PATH );
if ( ! preg_match( '#/metrics/?$#', $path ) ) {
return;
}
// Set flag to indicate we're handling a metrics request.
define( 'WP_PROMETHEUS_METRICS_REQUEST', true );
// 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 );
}
// Remove all content filters immediately to prevent recursion with Twig-based plugins.
// This is done for BOTH safe mode and isolated mode.
add_action( 'plugins_loaded', 'wp_prometheus_remove_content_filters', 0 );
// 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.
$autoloader = __DIR__ . '/vendor/autoload.php';
if ( ! file_exists( $autoloader ) ) {
return;
}
require_once $autoloader;
// Check license validity.
if ( ! \Magdev\WpPrometheus\License\Manager::is_license_valid() ) {
return; // Let normal flow handle unlicensed state.
}
// Authenticate.
$auth_token = get_option( 'wp_prometheus_auth_token', '' );
if ( empty( $auth_token ) ) {
status_header( 401 );
header( 'WWW-Authenticate: Bearer realm="WP Prometheus Metrics"' );
header( 'Content-Type: text/plain; charset=utf-8' );
echo 'Unauthorized';
exit;
}
// Check Bearer token.
$auth_header = '';
if ( isset( $_SERVER['HTTP_AUTHORIZATION'] ) ) {
$auth_header = sanitize_text_field( wp_unslash( $_SERVER['HTTP_AUTHORIZATION'] ) );
} elseif ( isset( $_SERVER['REDIRECT_HTTP_AUTHORIZATION'] ) ) {
$auth_header = sanitize_text_field( wp_unslash( $_SERVER['REDIRECT_HTTP_AUTHORIZATION'] ) );
}
$authenticated = false;
if ( ! empty( $auth_header ) && preg_match( '/Bearer\s+(.*)$/i', $auth_header, $matches ) ) {
$authenticated = hash_equals( $auth_token, $matches[1] );
}
// Check query parameter fallback.
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Auth token check.
if ( ! $authenticated && isset( $_GET['token'] ) ) {
$authenticated = hash_equals( $auth_token, sanitize_text_field( wp_unslash( $_GET['token'] ) ) );
}
if ( ! $authenticated ) {
status_header( 401 );
header( 'WWW-Authenticate: Bearer realm="WP Prometheus Metrics"' );
header( 'Content-Type: text/plain; charset=utf-8' );
echo 'Unauthorized';
exit;
}
// Set flag to indicate isolated mode - Collector will skip extensibility hooks.
define( 'WP_PROMETHEUS_ISOLATED_MODE', true );
// Output metrics and exit immediately.
$collector = new \Magdev\WpPrometheus\Metrics\Collector();
status_header( 200 );
header( 'Content-Type: text/plain; version=0.0.4; charset=utf-8' );
header( 'Cache-Control: no-cache, no-store, must-revalidate' );
header( 'Pragma: no-cache' );
header( 'Expires: 0' );
// phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Prometheus format.
echo $collector->render();
exit;
}
// Try early metrics handling before full plugin initialization.
wp_prometheus_early_metrics_check();
/**
* Plugin version.
*
* @var string
*/
define( 'WP_PROMETHEUS_VERSION', '0.3.0' );
define( 'WP_PROMETHEUS_VERSION', '0.4.7' );
/**
* Plugin file path.