10 Commits

Author SHA1 Message Date
9a94b4a7a5 feat: Add comprehensive PHPUnit test suite and CI/CD test gating (v0.5.0)
All checks were successful
Create Release Package / test (push) Successful in 1m13s
Create Release Package / build-release (push) Successful in 1m17s
189 tests across 8 test classes covering all core plugin classes:
CustomMetricBuilder, StorageFactory, Authentication, DashboardProvider,
RuntimeCollector, Installer, Collector, and MetricsEndpoint.

Added test job to Gitea release workflow that gates build-release.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 08:41:51 +01:00
1b1e818ff4 security: Fix XSS, insecure token generation, and harden import/export (v0.4.9)
Security audit findings addressed:
- Replace jQuery .html() with safe .text() DOM construction (XSS prevention)
- Use crypto.getRandomValues() instead of Math.random() for token generation
- Add 1MB import size limit to prevent DoS via large JSON payloads
- Remove site_url from metric exports (information disclosure)
- Add import mode allowlist validation

Refactoring:
- Extract shared wp_prometheus_authenticate_request() function (DRY)
- Extract showNotice() helper in admin.js (DRY)
- Extract is_hpos_enabled() helper in Collector (DRY)

Performance:
- Optimize WooCommerce product counting with paginate COUNT query

Housekeeping:
- Add missing options to Installer::uninstall() cleanup

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 07:47:37 +01:00
88ce597f1e feat: Add environment variable support for license settings, fix German umlauts
Add WP_PROMETHEUS_LICENSE_SERVER_URL, WP_PROMETHEUS_LICENSE_KEY, and
WP_PROMETHEUS_LICENSE_SERVER_SECRET environment variables for Docker/container
deployments. Admin UI disables inputs and shows override notice when env vars
are set. Help tab documents the new variables with Docker Compose example.

Fix all German (de_CH) translations to use proper umlauts (ä, ö, ü) instead
of ASCII digraph substitutions (ae, oe, ue). Swiss German uses umlauts
normally, only ß is replaced with ss.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 07:28:45 +01:00
9bfed06466 fix: Defer DashboardProvider translations to avoid early textdomain loading (v0.4.8)
All checks were successful
Create Release Package / build-release (push) Successful in 54s
DashboardProvider constructor also had __() calls during plugins_loaded.
Applied same lazy-initialization pattern as Settings tab labels.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-02-07 11:44:06 +01:00
b605d0c299 fix: Defer textdomain loading to init action for WordPress 6.7+ compatibility (v0.4.8)
All checks were successful
Create Release Package / build-release (push) Successful in 1m1s
Fixes _load_textdomain_just_in_time notice and headers already sent warnings
on admin pages by deferring load_plugin_textdomain() and Settings tab label
initialization to the init action instead of plugins_loaded.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-02-07 11:39:25 +01:00
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
34 changed files with 4665 additions and 395 deletions

View File

@@ -6,7 +6,32 @@ on:
- 'v*' - 'v*'
jobs: jobs:
test:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
submodules: recursive
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: '8.3'
extensions: mbstring, xml, zip, intl, gettext, redis, apcu
tools: composer:v2
- name: Validate composer.json
run: composer validate --strict
- name: Install Composer dependencies
run: composer install --optimize-autoloader --no-interaction
- name: Run tests
run: composer test
build-release: build-release:
needs: test
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout code - name: Checkout code

3
.gitignore vendored
View File

@@ -7,3 +7,6 @@ releases/*
# Marketing texts (not for distribution) # Marketing texts (not for distribution)
MARKETING.md MARKETING.md
# PHPUnit cache
.phpunit.cache

View File

@@ -5,6 +5,124 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [0.5.0] - 2026-02-26
### Added
- Comprehensive PHPUnit test suite with 189 tests and 329 assertions:
- CustomMetricBuilderTest (35 tests) - validation, CRUD, import/export
- AuthenticationTest (13 tests) - Bearer token, query param, header extraction
- StorageFactoryTest (25 tests) - adapter config, env vars, connection testing
- RuntimeCollectorTest (22 tests) - endpoint normalization, histograms, singleton
- DashboardProviderTest (27 tests) - registration validation, path traversal security
- InstallerTest (11 tests) - activation, deactivation, uninstall cleanup
- CollectorTest (10 tests) - registry, metric registration, render output
- MetricsEndpointTest (5 tests) - rewrite rules, request routing
- Test bootstrap with WordPress function stubs and GlobalFunctionState helper
- CI/CD test job in Gitea release workflow that gates release builds
- php-mock/php-mock-phpunit dependency for mocking WordPress functions in namespaced code
### Changed
- Release pipeline now requires passing tests before building release packages
## [0.4.9] - 2026-02-26
### Security
- Fixed XSS vulnerability: replaced all jQuery `.html()` injections with safe `.text()` DOM construction in admin.js
- Fixed insecure token generation: replaced `Math.random()` with `crypto.getRandomValues()` (Web Crypto API)
- Fixed XSS via string interpolation in `updateValueRows()`: replaced HTML string building with jQuery DOM construction
- Added 1 MB import size limit to prevent DoS via large JSON payloads in CustomMetricBuilder
- Removed `site_url` from metric export data to prevent information disclosure
- Added import mode validation (allowlist check) in CustomMetricBuilder
### Changed
- Extracted shared authentication logic (`wp_prometheus_authenticate_request()`) to eliminate code duplication between MetricsEndpoint and isolated mode handler
- Extracted `showNotice()` helper in admin.js to DRY up 10+ duplicated AJAX response handling patterns
- Extracted `is_hpos_enabled()` helper method in Collector to DRY up WooCommerce HPOS checks
- Optimized WooCommerce product type counting: uses `paginate: true` COUNT query instead of loading all product IDs into memory
- Added missing options to `Installer::uninstall()` cleanup (isolated_mode, storage adapter, Redis/APCu config)
## [0.4.8] - 2026-02-07
### Fixed
- Fixed `_load_textdomain_just_in_time` notice on admin pages (WordPress 6.7+ compatibility)
- Deferred `load_plugin_textdomain()` to `init` action instead of `plugins_loaded`
- Deferred Settings tab label and DashboardProvider initialization to avoid early translation loading
## [0.4.7] - 2026-02-03
### Added
- Database query duration distribution panel in Grafana Runtime dashboard
- `wordpress_db_query_duration_seconds` metric now listed in Help tab
- Documentation for enabling `SAVEQUERIES` constant for query timing
### Changed
- Updated README with instructions for enabling database query timing
- Grafana Runtime dashboard now includes bucket distribution chart for DB queries
## [0.4.6] - 2026-02-03
### Added
- Dashboard extension hook `wp_prometheus_register_dashboards` for third-party plugins
- Third-party plugins can now register their own Grafana dashboard templates
- Support for file-based and inline JSON dashboard registration
- "Extension" badge for third-party dashboards in admin UI
- Plugin attribution display for third-party dashboards
- Security: Path traversal protection for registered dashboard files
- Isolated mode support for dashboard registration hook
### Changed
- DashboardProvider now supports both built-in and third-party registered dashboards
- Dashboard cards show source (built-in vs extension) with visual distinction
## [0.4.5] - 2026-02-02
### Fixed
- Settings now persist correctly across Metrics sub-tabs
- Auth token no longer gets cleared when saving from Selection sub-tab
- Enabled metrics no longer get cleared when saving from Endpoint sub-tab
- Isolated mode setting no longer gets cleared when saving from other sub-tabs
### Changed
- Split Metrics settings into separate WordPress option groups per sub-tab
- Each sub-tab now uses its own settings group to prevent cross-tab overwrites
## [0.4.4] - 2026-02-02
### Added
- Safe mode for metrics collection (default):
- Removes problematic content filters early
- Allows third-party plugins to register `wp_prometheus_collect_metrics` hooks
- Wraps custom hooks in output buffering and try-catch for protection
- Isolated mode option for maximum compatibility:
- Outputs metrics before other plugins fully load
- Use only if Safe mode causes issues
- `WP_PROMETHEUS_ISOLATED_MODE` environment variable support
- Mode comparison table in admin settings
### Changed
- Replaced "early mode" with two clear modes: Safe (default) and Isolated
- Custom metrics hooks now fire by default with protection against recursion
- Filter removal now also includes `the_content_feed` and `comment_text`
- Updated admin UI with clearer explanations of each mode
### Fixed
- Third-party plugins can now add custom metrics without memory issues
- Twig-based plugins (like wp-fedistream) no longer cause recursion
## [0.4.3] - 2026-02-02 ## [0.4.3] - 2026-02-02
### Added ### Added

174
CLAUDE.md
View File

@@ -23,6 +23,7 @@ This plugin provides a Prometheus `/metrics` endpoint and an extensible way to a
- Grafana dashboard templates for easy visualization - Grafana dashboard templates for easy visualization
- Dedicated plugin settings under 'Settings/Metrics' menu - Dedicated plugin settings under 'Settings/Metrics' menu
- Extensible by other plugins using `wp_prometheus_collect_metrics` action hook - Extensible by other plugins using `wp_prometheus_collect_metrics` action hook
- Dashboard extension hook `wp_prometheus_register_dashboards` for third-party Grafana dashboards
- License management integration - License management integration
### Key Fact: 100% AI-Generated ### Key Fact: 100% AI-Generated
@@ -33,7 +34,9 @@ This project is proudly **"vibe-coded"** using Claude.AI - the entire codebase w
**Note for AI Assistants:** Clean this section after the specific features are done or new releases are made. Effective changes are tracked in `CHANGELOG.md`. Do not add completed versions here - document them in the Session History section at the end of this file. **Note for AI Assistants:** Clean this section after the specific features are done or new releases are made. Effective changes are tracked in `CHANGELOG.md`. Do not add completed versions here - document them in the Session History section at the end of this file.
*No planned features at this time.* ### Known Bugs
*No known bugs at this time.*
## Technical Stack ## Technical Stack
@@ -78,11 +81,7 @@ Text domain: `wp-prometheus`
- `en_US` - English (United States) [base language - .pot template] - `en_US` - English (United States) [base language - .pot template]
- `de_CH` - German (Switzerland, formal) - `de_CH` - German (Switzerland, formal)
To compile translations to .mo files for production: Translation compilation (.po → .mo) is handled automatically by CI/CD pipeline during release builds. No local compilation needed.
```bash
for po in languages/*.po; do msgfmt -o "${po%.po}.mo" "$po"; done
```
### Create releases ### Create releases
@@ -210,7 +209,7 @@ When editing CLAUDE.md or other markdown files, follow these rules to avoid lint
```txt ```txt
wp-prometheus/ wp-prometheus/
├── .gitea/workflows/ ├── .gitea/workflows/
│ └── release.yml # CI/CD pipeline │ └── release.yml # CI/CD pipeline (test + build)
├── assets/ ├── assets/
│ ├── css/ # Admin/Frontend styles │ ├── css/ # Admin/Frontend styles
│ ├── dashboards/ # Grafana dashboard templates │ ├── dashboards/ # Grafana dashboard templates
@@ -239,10 +238,28 @@ wp-prometheus/
│ ├── Installer.php # Activation/Deactivation │ ├── Installer.php # Activation/Deactivation
│ ├── Plugin.php # Main plugin class │ ├── Plugin.php # Main plugin class
│ └── index.php │ └── index.php
├── tests/
│ ├── bootstrap.php # WP constants + function stubs
│ ├── Helpers/
│ │ └── GlobalFunctionState.php # Controllable stub state
│ └── Unit/
│ ├── TestCase.php # Base class with PHPMock
│ ├── AuthenticationTest.php
│ ├── InstallerTest.php
│ ├── Admin/
│ │ └── DashboardProviderTest.php
│ ├── Endpoint/
│ │ └── MetricsEndpointTest.php
│ └── Metrics/
│ ├── CollectorTest.php
│ ├── CustomMetricBuilderTest.php
│ ├── RuntimeCollectorTest.php
│ └── StorageFactoryTest.php
├── CHANGELOG.md ├── CHANGELOG.md
├── CLAUDE.md ├── CLAUDE.md
├── composer.json ├── composer.json
├── index.php ├── index.php
├── phpunit.xml # PHPUnit 10 configuration
├── PLAN.md ├── PLAN.md
├── README.md ├── README.md
├── uninstall.php ├── uninstall.php
@@ -291,6 +308,145 @@ add_action( 'wp_prometheus_collect_metrics', function( $collector ) {
## Session History ## Session History
### 2026-02-26 - PHPUnit Test Suite & CI/CD Integration (v0.5.0)
- Created comprehensive PHPUnit test suite with 189 tests and 329 assertions
- Added `php-mock/php-mock-phpunit:^2.10` to composer.json require-dev
- Created test infrastructure:
- `tests/bootstrap.php`: ~45 WordPress function stubs with `if (!function_exists())` guards
- `tests/Helpers/GlobalFunctionState.php`: Static class for controlling stub behavior
- `tests/Unit/TestCase.php`: Abstract base class with PHPMock trait
- 8 test classes covering all core plugin classes:
- `CustomMetricBuilderTest` (35 tests) - validation, CRUD, import/export
- `StorageFactoryTest` (25 tests) - adapter config, env vars, connection tests
- `AuthenticationTest` (13 tests) - Bearer/query auth, header extraction
- `DashboardProviderTest` (27 tests) - registration, path traversal security
- `RuntimeCollectorTest` (22 tests) - endpoint normalization, histograms
- `InstallerTest` (11 tests) - activate, deactivate, uninstall cleanup
- `CollectorTest` (10 tests) - registry, register_gauge/counter/histogram, render
- `MetricsEndpointTest` (5 tests) - rewrite rules, request routing
- Added `test` job to `.gitea/workflows/release.yml` that gates `build-release`
- **Key Learning**: php-mock/php-mock-phpunit for WordPress testing without WP environment
- Intercepts unqualified function calls in namespaced code via PHP namespace fallback
- `$this->getFunctionMock('Namespace', 'function_name')` creates expectations
- Does NOT work for functions called from global namespace (bootstrap stubs) or in PHP 8.4 for some edge cases
- Solution for global-scope stubs: Make them controllable via `GlobalFunctionState::$options`
- **Key Learning**: Testing singletons and static state
- Use `ReflectionClass::newInstanceWithoutConstructor()` to bypass private constructors
- Reset static `$instance` properties via `ReflectionProperty::setValue(null, null)` in tearDown
- Always reset StorageFactory and RuntimeCollector singletons between tests
- **Key Learning**: CI/CD pipeline structure for test gating
- `test` job uses `composer install` (WITH dev deps) to run tests
- `build-release` job uses `--no-dev` (unchanged) for production builds
- `needs: test` dependency ensures failing tests block releases
### 2026-02-26 - Security Audit & Refactoring (v0.4.9)
- Fixed XSS vulnerabilities in admin.js (jQuery `.html()` → safe DOM construction)
- Fixed insecure token generation (`Math.random()` → Web Crypto API)
- Added 1 MB import size limit, import mode validation, removed `site_url` from exports
- Extracted shared authentication logic and helper methods
- Optimized WooCommerce product counting with COUNT query
### 2026-02-07 - Fix Early Textdomain Loading (v0.4.8)
- Fixed `_load_textdomain_just_in_time` warning on admin pages (WordPress 6.7+ compatibility)
- Root cause: `load_plugin_textdomain()` was called during `plugins_loaded` in `Plugin::__construct()`
- WordPress 6.7+ requires textdomain loading at the `init` action or later
- Three classes needed fixing:
- `Plugin.php`: Deferred `load_textdomain()` to `init` action hook, changed method visibility to public
- `Settings.php`: Deferred tab label initialization (which uses `__()`) to a lazy `get_tabs()` method
- `DashboardProvider.php`: Deferred built-in dashboard definitions (with `__()` calls) to a lazy `get_builtin_dashboards()` method
- Cleared Known Bugs section — no remaining known issues
- **Key Learning**: WordPress 6.7 textdomain loading requirements
- `load_plugin_textdomain()` must be called at `init` or later
- WordPress's JIT textdomain loader (`_load_textdomain_just_in_time`) also triggers too-early warnings
- Any `__()` / `_e()` calls before `init` for a plugin textdomain will trigger the notice
- The warning causes "headers already sent" errors because the notice output breaks header modifications
- Solution: Defer both explicit `load_plugin_textdomain()` and any `__()` calls to `init` or later hooks
### 2026-02-03 - Database Query Timing Documentation (v0.4.7)
- Added database query duration distribution panel to Grafana Runtime dashboard
- Added `wordpress_db_query_duration_seconds` metric to Help tab metrics reference
- Added documentation in README explaining how to enable `SAVEQUERIES` for query timing
- Updated translation files (.pot and .po) with new strings
- **Key Learning**: WordPress `SAVEQUERIES` constant
- Enables `$wpdb->queries` array with query strings, timing, and call stacks
- Required for `wordpress_db_query_duration_seconds` histogram metric
- Has performance overhead - recommended for development, use cautiously in production
- Without it, only query counts are available (not timing data)
### 2026-02-03 - Dashboard Extension Hook (v0.4.6)
- Added `wp_prometheus_register_dashboards` action hook for third-party plugins
- Third-party plugins can now register their own Grafana dashboard templates
- Implementation in `DashboardProvider.php`:
- `register_dashboard(slug, args)` method for registrations
- Supports file-based dashboards (absolute path to JSON) or inline JSON content
- Security: Path traversal protection (files must be under `WP_CONTENT_DIR`)
- `fire_registration_hook()` with output buffering and exception handling
- Respects isolated mode setting (skips third-party hooks when enabled)
- `is_third_party()` and `get_plugin_name()` helper methods
- Updated admin UI in Settings.php:
- "Extension" badge displayed on third-party dashboard cards
- Plugin attribution shown below third-party dashboards
- Visual distinction with blue border for third-party cards
- **Key Learning**: Extension hook design pattern
- Fire hook lazily on first `get_available()` call, not in constructor
- Use `$hook_fired` flag to prevent double-firing
- Wrap hook execution in try-catch to isolate failures
- Validate registrations thoroughly before accepting them
- **Key Learning**: Security for file-based registrations
- Require absolute paths (`path_is_absolute()`)
- Validate files exist and are readable
- Use `realpath()` to resolve symlinks and prevent traversal
- Restrict to `WP_CONTENT_DIR` (not just plugin directories)
### 2026-02-02 - Settings Persistence Fix (v0.4.5)
- Fixed critical bug where settings would get cleared when saving from different Metrics sub-tabs
- Root cause: All settings were registered under single `wp_prometheus_metrics_settings` group
- When saving from "Endpoint" sub-tab, only auth token was in POST data
- WordPress Settings API would process all registered settings in the group
- Missing fields (enabled_metrics, isolated_mode) would receive null/undefined
- Sanitize callbacks returned empty values, overwriting existing settings
- Solution: Split into separate settings groups per sub-tab:
- `wp_prometheus_endpoint_settings` for auth token
- `wp_prometheus_selection_settings` for enabled metrics
- `wp_prometheus_advanced_settings` for isolated mode
- **Key Learning**: WordPress Settings API and multiple forms
- When multiple forms share the same settings group, saving one form can clear settings from another
- Each form with `settings_fields()` should use a unique option group
- `register_setting()` group name must match `settings_fields()` group name
### 2026-02-02 - Safe Mode & Custom Hooks Fix (v0.4.4)
- Redesigned metrics collection to support both plugin compatibility AND custom metrics:
- **Safe Mode (default)**: Removes content filters early but lets WordPress load normally
- **Isolated Mode**: Legacy early mode that skips custom hooks entirely
- Implementation:
- `WP_PROMETHEUS_METRICS_REQUEST` constant set for any /metrics request
- Content filters removed via `plugins_loaded` hook at priority 0
- Collector fires `wp_prometheus_collect_metrics` with protection (output buffering, try-catch)
- `wp_prometheus_isolated_mode` option replaces `wp_prometheus_disable_early_mode`
- `WP_PROMETHEUS_ISOLATED_MODE` environment variable for containerized deployments
- Collector now wraps custom hooks in `fire_custom_metrics_hook()` method:
- Removes content filters again before hook (in case re-added)
- Uses output buffering to discard accidental output
- Catches exceptions to prevent breaking metrics output
- Logs errors when WP_DEBUG is enabled
- Updated admin UI with mode comparison table
- **Key Learning**: Hybrid approach for plugin compatibility
- The memory issue comes from content filter recursion, not just plugin loading
- Removing filters early (before any plugin can trigger them) prevents recursion
- Plugins still load and can register their `wp_prometheus_collect_metrics` hooks
- Hooks fire after filters are removed, in a protected context
- **Key Learning**: Defense in depth for custom hooks
- Remove filters again right before hook fires (plugins may re-add them)
- Output buffering catches any echo/print from misbehaving plugins
- Try-catch prevents one broken plugin from breaking metrics entirely
### 2026-02-02 - Sub-tabs & Early Mode Fix (v0.4.3) ### 2026-02-02 - Sub-tabs & Early Mode Fix (v0.4.3)
- Split Metrics tab into sub-tabs for better organization: - Split Metrics tab into sub-tabs for better organization:
@@ -304,6 +460,10 @@ add_action( 'wp_prometheus_collect_metrics', function( $collector ) {
- Settings must be inside `<form action="options.php">` with `settings_fields()` call - Settings must be inside `<form action="options.php">` with `settings_fields()` call
- Each sub-tab needs its own form wrapper for proper saving - Each sub-tab needs its own form wrapper for proper saving
- Sub-tabs use URL query parameter (`subtab`) within the main tab - Sub-tabs use URL query parameter (`subtab`) within the main tab
- **Key Learning**: WordPress plugin versioning requires TWO updates
- Plugin header comment `Version: x.x.x` (line ~6) - used by WordPress admin
- PHP constant `WP_PROMETHEUS_VERSION` (line ~133) - used internally
- CI/CD checks both must match the git tag, causing release failures if mismatched
### 2026-02-02 - Early Mode Toggle (v0.4.2) ### 2026-02-02 - Early Mode Toggle (v0.4.2)

106
README.md
View File

@@ -6,7 +6,13 @@ A WordPress plugin that provides a Prometheus-compatible `/metrics` endpoint wit
- Prometheus-compatible authenticated `/metrics` endpoint - Prometheus-compatible authenticated `/metrics` endpoint
- Default WordPress metrics (users, posts, comments, plugins) - Default WordPress metrics (users, posts, comments, plugins)
- Runtime metrics (HTTP requests, database queries)
- Cron job and transient cache metrics
- WooCommerce integration (products, orders, revenue)
- Custom metric builder with admin UI
- Grafana dashboard templates with download
- Extensible by other plugins using hooks - Extensible by other plugins using hooks
- Dashboard extension hook for third-party Grafana dashboards
- Settings page under Settings > Metrics - Settings page under Settings > Metrics
- Bearer token authentication - Bearer token authentication
- License management integration - License management integration
@@ -92,6 +98,21 @@ scrape_configs:
**Note:** Runtime metrics aggregate data across requests. Enable only the metrics you need to minimize performance impact. **Note:** Runtime metrics aggregate data across requests. Enable only the metrics you need to minimize performance impact.
#### Enabling Database Query Timing
The `wordpress_db_query_duration_seconds` histogram requires WordPress's `SAVEQUERIES` constant to be enabled. Add this to your `wp-config.php`:
```php
define( 'SAVEQUERIES', true );
```
**Important considerations:**
- `SAVEQUERIES` has a performance overhead as it logs all queries with timing and call stacks
- Recommended for development/staging environments, use with caution in production
- Without `SAVEQUERIES`, only query counts (`wordpress_db_queries_total`) are available
- The histogram shows total query time per request, grouped by endpoint
### Cron Metrics (v0.2.0+) ### Cron Metrics (v0.2.0+)
| Metric | Type | Labels | Description | | Metric | Type | Labels | Description |
@@ -154,8 +175,93 @@ $histogram = $collector->register_histogram( $name, $help, $labels, $buckets );
$histogram->observe( $value, $labelValues ); $histogram->observe( $value, $labelValues );
``` ```
## Extending with Custom Dashboards (v0.4.6+)
Add your own Grafana dashboard templates using the `wp_prometheus_register_dashboards` action:
```php
add_action( 'wp_prometheus_register_dashboards', function( $provider ) {
// File-based dashboard
$provider->register_dashboard( 'my-plugin-dashboard', array(
'title' => __( 'My Plugin Metrics', 'my-plugin' ),
'description' => __( 'Dashboard for my custom metrics', 'my-plugin' ),
'icon' => 'dashicons-chart-bar',
'file' => MY_PLUGIN_PATH . 'assets/dashboards/my-dashboard.json',
'plugin' => 'My Plugin Name',
) );
// OR inline JSON dashboard
$provider->register_dashboard( 'dynamic-dashboard', array(
'title' => __( 'Dynamic Dashboard', 'my-plugin' ),
'description' => __( 'Dynamically generated dashboard', 'my-plugin' ),
'icon' => 'dashicons-admin-generic',
'json' => json_encode( $dashboard_array ),
'plugin' => 'My Plugin Name',
) );
} );
```
### Registration Parameters
| Parameter | Required | Description |
| --------- | -------- | ----------- |
| `title` | Yes | Dashboard title displayed in admin |
| `description` | No | Description shown below the title |
| `icon` | No | Dashicon class (default: `dashicons-chart-line`) |
| `file` | Yes* | Absolute path to JSON file |
| `json` | Yes* | Inline JSON content |
| `plugin` | No | Plugin name for attribution |
*Either `file` or `json` is required, but not both.
### Security Notes
- File paths must be absolute and within `wp-content/`
- Inline JSON is validated during registration
- Third-party dashboards are marked with an "Extension" badge in the admin UI
## Development ## Development
### Testing
The plugin includes a comprehensive PHPUnit test suite with 189 tests covering all core classes.
```bash
# Install dependencies (including dev)
composer install
# Run the full test suite
composer test
```
#### Test Architecture
Tests use [php-mock/php-mock-phpunit](https://github.com/php-mock/php-mock-phpunit) to mock WordPress functions called from namespaced plugin code, and a `GlobalFunctionState` helper for controlling global-scope function stubs (authentication, options, conditionals).
```
tests/
├── bootstrap.php # WP constants + function stubs
├── Helpers/
│ └── GlobalFunctionState.php # Controllable state for stubs
└── Unit/
├── TestCase.php # Base class with PHPMock trait
├── AuthenticationTest.php # Bearer/query auth, header extraction
├── InstallerTest.php # Activate, deactivate, uninstall
├── Metrics/
│ ├── CustomMetricBuilderTest.php # Validation, CRUD, import/export
│ ├── RuntimeCollectorTest.php # Endpoint normalization, histograms
│ ├── StorageFactoryTest.php # Adapter config, env vars, connections
│ └── CollectorTest.php # Registry, register_*, render
├── Admin/
│ └── DashboardProviderTest.php # Registration, path security
└── Endpoint/
└── MetricsEndpointTest.php # Rewrite rules, request routing
```
#### CI/CD Integration
Tests run automatically in the Gitea CI/CD pipeline before release builds. A failing test suite blocks the release.
### Build for Release ### Build for Release
```bash ```bash

View File

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

View File

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

View File

@@ -235,31 +235,19 @@
$spinner.removeClass('is-active'); $spinner.removeClass('is-active');
if (response.success) { if (response.success) {
$message showNotice($message, response.data.message, 'success');
.removeClass('notice-error')
.addClass('notice notice-success')
.html('<p>' + response.data.message + '</p>')
.show();
// Reload page after successful validation/activation. // Reload page after successful validation/activation.
setTimeout(function() { setTimeout(function() {
location.reload(); location.reload();
}, 1500); }, 1500);
} else { } else {
$message showNotice($message, response.data.message || 'An error occurred.', 'error');
.removeClass('notice-success')
.addClass('notice notice-error')
.html('<p>' + (response.data.message || 'An error occurred.') + '</p>')
.show();
} }
}, },
error: function() { error: function() {
$spinner.removeClass('is-active'); $spinner.removeClass('is-active');
$message showNotice($message, 'Connection error. Please try again.', 'error');
.removeClass('notice-success')
.addClass('notice notice-error')
.html('<p>Connection error. Please try again.</p>')
.show();
} }
}); });
} }
@@ -287,30 +275,18 @@
$spinner.removeClass('is-active'); $spinner.removeClass('is-active');
if (response.success) { if (response.success) {
$message showNotice($message, response.data.message, 'success');
.removeClass('notice-error')
.addClass('notice notice-success')
.html('<p>' + response.data.message + '</p>')
.show();
setTimeout(function() { setTimeout(function() {
window.location.href = window.location.pathname + '?page=wp-prometheus&tab=custom'; window.location.href = window.location.pathname + '?page=wp-prometheus&tab=custom';
}, 1000); }, 1000);
} else { } else {
$message showNotice($message, response.data.message || 'An error occurred.', 'error');
.removeClass('notice-success')
.addClass('notice notice-error')
.html('<p>' + (response.data.message || 'An error occurred.') + '</p>')
.show();
} }
}, },
error: function() { error: function() {
$spinner.removeClass('is-active'); $spinner.removeClass('is-active');
$message showNotice($message, 'Connection error. Please try again.', 'error');
.removeClass('notice-success')
.addClass('notice notice-error')
.html('<p>Connection error. Please try again.</p>')
.show();
} }
}); });
} }
@@ -398,11 +374,7 @@
$spinner.removeClass('is-active'); $spinner.removeClass('is-active');
if (response.success) { if (response.success) {
$message showNotice($message, response.data.message, 'success');
.removeClass('notice-error')
.addClass('notice notice-success')
.html('<p>' + response.data.message + '</p>')
.show();
$('#import-options').slideUp(); $('#import-options').slideUp();
$('#import-metrics-file').val(''); $('#import-metrics-file').val('');
@@ -412,20 +384,12 @@
location.reload(); location.reload();
}, 1500); }, 1500);
} else { } else {
$message showNotice($message, response.data.message || 'An error occurred.', 'error');
.removeClass('notice-success')
.addClass('notice notice-error')
.html('<p>' + (response.data.message || 'An error occurred.') + '</p>')
.show();
} }
}, },
error: function() { error: function() {
$spinner.removeClass('is-active'); $spinner.removeClass('is-active');
$message showNotice($message, 'Connection error. Please try again.', 'error');
.removeClass('notice-success')
.addClass('notice notice-error')
.html('<p>Connection error. Please try again.</p>')
.show();
} }
}); });
} }
@@ -478,26 +442,14 @@
$spinner.removeClass('is-active'); $spinner.removeClass('is-active');
if (response.success) { if (response.success) {
$message showNotice($message, response.data.message, 'success');
.removeClass('notice-error')
.addClass('notice notice-success')
.html('<p>' + response.data.message + '</p>')
.show();
} else { } else {
$message showNotice($message, response.data.message || 'An error occurred.', 'error');
.removeClass('notice-success')
.addClass('notice notice-error')
.html('<p>' + (response.data.message || 'An error occurred.') + '</p>')
.show();
} }
}, },
error: function() { error: function() {
$spinner.removeClass('is-active'); $spinner.removeClass('is-active');
$message showNotice($message, 'Connection error. Please try again.', 'error');
.removeClass('notice-success')
.addClass('notice notice-error')
.html('<p>Connection error. Please try again.</p>')
.show();
} }
}); });
} }
@@ -546,15 +498,30 @@
// Remove all inputs except the value and button. // Remove all inputs except the value and button.
$row.find('input').remove(); $row.find('input').remove();
// Re-add label inputs. // Re-add label inputs using safe DOM construction.
for (var i = 0; i < labelCount; i++) { for (var i = 0; i < labelCount; i++) {
var val = currentValues[i] || ''; var val = currentValues[i] || '';
$row.prepend('<input type="text" name="label_values[' + rowIndex + '][]" class="small-text" placeholder="' + (labels[i] || 'value') + '" value="' + val + '">'); var $input = $('<input>', {
type: 'text',
name: 'label_values[' + rowIndex + '][]',
'class': 'small-text',
placeholder: labels[i] || 'value',
value: val
});
$row.prepend($input);
} }
// Re-add value input. // Re-add value input using safe DOM construction.
var metricVal = currentValues[currentValues.length - 1] || ''; var metricVal = currentValues[currentValues.length - 1] || '';
$row.find('.remove-value-row').before('<input type="number" name="label_values[' + rowIndex + '][]" class="small-text" step="any" placeholder="Value" value="' + metricVal + '">'); var $valueInput = $('<input>', {
type: 'number',
name: 'label_values[' + rowIndex + '][]',
'class': 'small-text',
step: 'any',
placeholder: 'Value',
value: metricVal
});
$row.find('.remove-value-row').before($valueInput);
}); });
} }
@@ -584,7 +551,7 @@
} }
/** /**
* Generate a random token. * Generate a cryptographically secure random token.
* *
* @param {number} length Token length. * @param {number} length Token length.
* @return {string} Generated token. * @return {string} Generated token.
@@ -592,12 +559,31 @@
function generateToken(length) { function generateToken(length) {
var charset = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'; var charset = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
var token = ''; var token = '';
var randomValues = new Uint32Array(length);
window.crypto.getRandomValues(randomValues);
for (var i = 0; i < length; i++) { for (var i = 0; i < length; i++) {
token += charset.charAt(Math.floor(Math.random() * charset.length)); token += charset.charAt(randomValues[i] % charset.length);
} }
return token; return token;
} }
/**
* Show a notice message safely (XSS-safe).
*
* @param {jQuery} $element The message container element.
* @param {string} message The message text.
* @param {string} type Notice type: 'success', 'error', or 'warning'.
*/
function showNotice($element, message, type) {
var removeClasses = 'notice-error notice-success notice-warning';
$element
.removeClass(removeClasses)
.addClass('notice notice-' + type)
.empty()
.append($('<p>').text(message))
.show();
}
/** /**
* Download a file. * Download a file.
* *
@@ -666,12 +652,8 @@
$spinner.removeClass('is-active'); $spinner.removeClass('is-active');
if (response.success) { if (response.success) {
var noticeClass = response.data.warning ? 'notice-warning' : 'notice-success'; var type = response.data.warning ? 'warning' : 'success';
$message showNotice($message, response.data.message, type);
.removeClass('notice-error notice-success notice-warning')
.addClass('notice ' + noticeClass)
.html('<p>' + response.data.message + '</p>')
.show();
if (!response.data.warning) { if (!response.data.warning) {
setTimeout(function() { setTimeout(function() {
@@ -679,20 +661,12 @@
}, 1500); }, 1500);
} }
} else { } else {
$message showNotice($message, response.data.message || 'An error occurred.', 'error');
.removeClass('notice-success notice-warning')
.addClass('notice notice-error')
.html('<p>' + (response.data.message || 'An error occurred.') + '</p>')
.show();
} }
}, },
error: function() { error: function() {
$spinner.removeClass('is-active'); $spinner.removeClass('is-active');
$message showNotice($message, 'Connection error. Please try again.', 'error');
.removeClass('notice-success notice-warning')
.addClass('notice notice-error')
.html('<p>Connection error. Please try again.</p>')
.show();
} }
}); });
} }
@@ -720,26 +694,14 @@
$spinner.removeClass('is-active'); $spinner.removeClass('is-active');
if (response.success) { if (response.success) {
$message showNotice($message, response.data.message, 'success');
.removeClass('notice-error notice-warning')
.addClass('notice notice-success')
.html('<p>' + response.data.message + '</p>')
.show();
} else { } else {
$message showNotice($message, response.data.message || 'Connection test failed.', 'error');
.removeClass('notice-success notice-warning')
.addClass('notice notice-error')
.html('<p>' + (response.data.message || 'Connection test failed.') + '</p>')
.show();
} }
}, },
error: function() { error: function() {
$spinner.removeClass('is-active'); $spinner.removeClass('is-active');
$message showNotice($message, 'Connection error. Please try again.', 'error');
.removeClass('notice-success notice-warning')
.addClass('notice notice-error')
.html('<p>Connection error. Please try again.</p>')
.show();
} }
}); });
} }

View File

@@ -32,6 +32,7 @@
"promphp/prometheus_client_php": "^2.10" "promphp/prometheus_client_php": "^2.10"
}, },
"require-dev": { "require-dev": {
"php-mock/php-mock-phpunit": "^2.10",
"phpunit/phpunit": "^10.0", "phpunit/phpunit": "^10.0",
"squizlabs/php_codesniffer": "^3.7", "squizlabs/php_codesniffer": "^3.7",
"wp-coding-standards/wpcs": "^3.0", "wp-coding-standards/wpcs": "^3.0",

209
composer.lock generated
View File

@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically" "This file is @generated automatically"
], ],
"content-hash": "3cacd9c609c3c49fb4600d09a38c04be", "content-hash": "cfd3853b3cf76d82f972f3326e4f94d3",
"packages": [ "packages": [
{ {
"name": "magdev/wc-licensed-product-client", "name": "magdev/wc-licensed-product-client",
@@ -1128,6 +1128,213 @@
}, },
"time": "2022-02-21T01:04:05+00:00" "time": "2022-02-21T01:04:05+00:00"
}, },
{
"name": "php-mock/php-mock",
"version": "2.7.0",
"source": {
"type": "git",
"url": "https://github.com/php-mock/php-mock.git",
"reference": "b59734f19765296bb0311942850d02288a224890"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/php-mock/php-mock/zipball/b59734f19765296bb0311942850d02288a224890",
"reference": "b59734f19765296bb0311942850d02288a224890",
"shasum": ""
},
"require": {
"php": "^5.6 || ^7.0 || ^8.0",
"phpunit/php-text-template": "^1 || ^2 || ^3 || ^4 || ^5 || ^6"
},
"replace": {
"malkusch/php-mock": "*"
},
"require-dev": {
"phpunit/phpunit": "^5.7 || ^6.5 || ^7.5 || ^8.0 || ^9.0 || ^10.0 || ^11.0 || ^12.0 || ^13.0",
"squizlabs/php_codesniffer": "^3.8"
},
"suggest": {
"php-mock/php-mock-phpunit": "Allows integration into PHPUnit testcase with the trait PHPMock."
},
"type": "library",
"autoload": {
"files": [
"autoload.php"
],
"psr-4": {
"phpmock\\": [
"classes/",
"tests/"
]
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"WTFPL"
],
"authors": [
{
"name": "Markus Malkusch",
"email": "markus@malkusch.de",
"homepage": "http://markus.malkusch.de",
"role": "Developer"
}
],
"description": "PHP-Mock can mock built-in PHP functions (e.g. time()). PHP-Mock relies on PHP's namespace fallback policy. No further extension is needed.",
"homepage": "https://github.com/php-mock/php-mock",
"keywords": [
"BDD",
"TDD",
"function",
"mock",
"stub",
"test",
"test double",
"testing"
],
"support": {
"issues": "https://github.com/php-mock/php-mock/issues",
"source": "https://github.com/php-mock/php-mock/tree/2.7.0"
},
"funding": [
{
"url": "https://github.com/michalbundyra",
"type": "github"
}
],
"time": "2026-02-06T07:39:37+00:00"
},
{
"name": "php-mock/php-mock-integration",
"version": "3.1.0",
"source": {
"type": "git",
"url": "https://github.com/php-mock/php-mock-integration.git",
"reference": "cbbf39705ec13dece5b04133cef4e2fd3137a345"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/php-mock/php-mock-integration/zipball/cbbf39705ec13dece5b04133cef4e2fd3137a345",
"reference": "cbbf39705ec13dece5b04133cef4e2fd3137a345",
"shasum": ""
},
"require": {
"php": ">=5.6",
"php-mock/php-mock": "^2.5",
"phpunit/php-text-template": "^1 || ^2 || ^3 || ^4 || ^5 || ^6"
},
"require-dev": {
"phpunit/phpunit": "^5.7.27 || ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || ^13"
},
"type": "library",
"autoload": {
"psr-4": {
"phpmock\\integration\\": "classes/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"WTFPL"
],
"authors": [
{
"name": "Markus Malkusch",
"email": "markus@malkusch.de",
"homepage": "http://markus.malkusch.de",
"role": "Developer"
}
],
"description": "Integration package for PHP-Mock",
"homepage": "https://github.com/php-mock/php-mock-integration",
"keywords": [
"BDD",
"TDD",
"function",
"mock",
"stub",
"test",
"test double"
],
"support": {
"issues": "https://github.com/php-mock/php-mock-integration/issues",
"source": "https://github.com/php-mock/php-mock-integration/tree/3.1.0"
},
"funding": [
{
"url": "https://github.com/michalbundyra",
"type": "github"
}
],
"time": "2026-02-06T07:44:43+00:00"
},
{
"name": "php-mock/php-mock-phpunit",
"version": "2.15.0",
"source": {
"type": "git",
"url": "https://github.com/php-mock/php-mock-phpunit.git",
"reference": "701df15b183f25af663af134eb71353cd838b955"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/php-mock/php-mock-phpunit/zipball/701df15b183f25af663af134eb71353cd838b955",
"reference": "701df15b183f25af663af134eb71353cd838b955",
"shasum": ""
},
"require": {
"php": ">=7",
"php-mock/php-mock-integration": "^3.0",
"phpunit/phpunit": "^6 || ^7 || ^8 || ^9 || ^10.0.17 || ^11 || ^12.0.9 || ^13"
},
"require-dev": {
"mockery/mockery": "^1.3.6"
},
"type": "library",
"autoload": {
"files": [
"autoload.php"
],
"psr-4": {
"phpmock\\phpunit\\": "classes/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"WTFPL"
],
"authors": [
{
"name": "Markus Malkusch",
"email": "markus@malkusch.de",
"homepage": "http://markus.malkusch.de",
"role": "Developer"
}
],
"description": "Mock built-in PHP functions (e.g. time()) with PHPUnit. This package relies on PHP's namespace fallback policy. No further extension is needed.",
"homepage": "https://github.com/php-mock/php-mock-phpunit",
"keywords": [
"BDD",
"TDD",
"function",
"mock",
"phpunit",
"stub",
"test",
"test double",
"testing"
],
"support": {
"issues": "https://github.com/php-mock/php-mock-phpunit/issues",
"source": "https://github.com/php-mock/php-mock-phpunit/tree/2.15.0"
},
"funding": [
{
"url": "https://github.com/michalbundyra",
"type": "github"
}
],
"time": "2026-02-06T09:12:10+00:00"
},
{ {
"name": "phpcompatibility/php-compatibility", "name": "phpcompatibility/php-compatibility",
"version": "9.3.5", "version": "9.3.5",

Binary file not shown.

View File

@@ -45,11 +45,11 @@ msgstr "Lizenz-Einstellungen gespeichert."
#: src/Admin/Settings.php #: src/Admin/Settings.php
msgid "License is active and valid." msgid "License is active and valid."
msgstr "Lizenz ist aktiv und gueltig." msgstr "Lizenz ist aktiv und gültig."
#: src/Admin/Settings.php #: src/Admin/Settings.php
msgid "License is invalid." msgid "License is invalid."
msgstr "Lizenz ist ungueltig." msgstr "Lizenz ist ungültig."
#: src/Admin/Settings.php #: src/Admin/Settings.php
msgid "License has expired." msgid "License has expired."
@@ -78,12 +78,12 @@ msgstr "Unbekannter Status."
#. translators: %s: Expiration date #. translators: %s: Expiration date
#: src/Admin/Settings.php #: src/Admin/Settings.php
msgid "Expires: %s" msgid "Expires: %s"
msgstr "Laeuft ab: %s" msgstr "Läuft ab: %s"
#. translators: %s: Time ago #. translators: %s: Time ago
#: src/Admin/Settings.php #: src/Admin/Settings.php
msgid "Last checked: %s ago" msgid "Last checked: %s ago"
msgstr "Zuletzt geprueft: vor %s" msgstr "Zuletzt geprüft: vor %s"
#: src/Admin/Settings.php #: src/Admin/Settings.php
msgid "License Server URL" msgid "License Server URL"
@@ -91,7 +91,7 @@ msgstr "Lizenz-Server URL"
#: src/Admin/Settings.php #: src/Admin/Settings.php
msgid "License Key" msgid "License Key"
msgstr "Lizenzschluessel" msgstr "Lizenzschlüssel"
#: src/Admin/Settings.php #: src/Admin/Settings.php
msgid "Server Secret" msgid "Server Secret"
@@ -101,6 +101,18 @@ msgstr "Server-Geheimnis"
msgid "Leave empty to keep existing." msgid "Leave empty to keep existing."
msgstr "Leer lassen, um bestehenden Wert zu behalten." msgstr "Leer lassen, um bestehenden Wert zu behalten."
#: src/Admin/Settings.php
msgid "Overridden by WP_PROMETHEUS_LICENSE_SERVER_URL environment variable."
msgstr "Überschrieben durch die Umgebungsvariable WP_PROMETHEUS_LICENSE_SERVER_URL."
#: src/Admin/Settings.php
msgid "Overridden by WP_PROMETHEUS_LICENSE_KEY environment variable."
msgstr "Überschrieben durch die Umgebungsvariable WP_PROMETHEUS_LICENSE_KEY."
#: src/Admin/Settings.php
msgid "Overridden by WP_PROMETHEUS_LICENSE_SERVER_SECRET environment variable."
msgstr "Überschrieben durch die Umgebungsvariable WP_PROMETHEUS_LICENSE_SERVER_SECRET."
#: src/Admin/Settings.php #: src/Admin/Settings.php
msgid "Save License Settings" msgid "Save License Settings"
msgstr "Lizenz-Einstellungen speichern" msgstr "Lizenz-Einstellungen speichern"
@@ -123,11 +135,11 @@ msgstr "Aktivierte Metriken"
#: src/Admin/Settings.php #: src/Admin/Settings.php
msgid "Configure authentication for the /metrics endpoint." msgid "Configure authentication for the /metrics endpoint."
msgstr "Authentifizierung fuer den /metrics-Endpunkt konfigurieren." msgstr "Authentifizierung für den /metrics-Endpunkt konfigurieren."
#: src/Admin/Settings.php #: src/Admin/Settings.php
msgid "Select which metrics to expose on the /metrics endpoint." msgid "Select which metrics to expose on the /metrics endpoint."
msgstr "Waehlen Sie, welche Metriken auf dem /metrics-Endpunkt bereitgestellt werden sollen." msgstr "Wählen Sie, welche Metriken auf dem /metrics-Endpunkt bereitgestellt werden sollen."
#: src/Admin/Settings.php #: src/Admin/Settings.php
msgid "Auth Token" msgid "Auth Token"
@@ -135,7 +147,7 @@ msgstr "Auth-Token"
#: src/Admin/Settings.php #: src/Admin/Settings.php
msgid "Select Metrics" msgid "Select Metrics"
msgstr "Metriken auswaehlen" msgstr "Metriken auswählen"
#: src/Admin/Settings.php #: src/Admin/Settings.php
msgid "Regenerate" msgid "Regenerate"
@@ -159,7 +171,7 @@ msgstr "Benutzer nach Rolle"
#: src/Admin/Settings.php #: src/Admin/Settings.php
msgid "Total Posts by Type and Status" msgid "Total Posts by Type and Status"
msgstr "Beitraege nach Typ und Status" msgstr "Beiträge nach Typ und Status"
#: src/Admin/Settings.php #: src/Admin/Settings.php
msgid "Total Comments by Status" msgid "Total Comments by Status"
@@ -171,7 +183,7 @@ msgstr "Plugins (aktiv/inaktiv)"
#: src/Admin/Settings.php #: src/Admin/Settings.php
msgid "Cron Events (scheduled tasks, overdue, next run)" msgid "Cron Events (scheduled tasks, overdue, next run)"
msgstr "Cron-Ereignisse (geplante Aufgaben, ueberfaellig, naechste Ausfuehrung)" msgstr "Cron-Ereignisse (geplante Aufgaben, überfällig, nächste Ausführung)"
#: src/Admin/Settings.php #: src/Admin/Settings.php
msgid "Transients (total, expiring, expired)" msgid "Transients (total, expiring, expired)"
@@ -183,7 +195,7 @@ msgstr "Laufzeit-Metriken"
#: src/Admin/Settings.php #: src/Admin/Settings.php
msgid "Runtime metrics track data across requests. Enable only what you need to minimize performance impact." msgid "Runtime metrics track data across requests. Enable only what you need to minimize performance impact."
msgstr "Laufzeit-Metriken erfassen Daten ueber Anfragen hinweg. Aktivieren Sie nur, was Sie benoetigen, um Auswirkungen auf die Leistung zu minimieren." msgstr "Laufzeit-Metriken erfassen Daten über Anfragen hinweg. Aktivieren Sie nur, was Sie benötigen, um Auswirkungen auf die Leistung zu minimieren."
#: src/Admin/Settings.php #: src/Admin/Settings.php
msgid "HTTP Requests Total (by method, status, endpoint)" msgid "HTTP Requests Total (by method, status, endpoint)"
@@ -203,7 +215,7 @@ msgstr "WooCommerce-Metriken"
#: src/Admin/Settings.php #: src/Admin/Settings.php
msgid "Metrics specific to WooCommerce stores. Only available when WooCommerce is active." msgid "Metrics specific to WooCommerce stores. Only available when WooCommerce is active."
msgstr "Metriken speziell fuer WooCommerce-Shops. Nur verfuegbar, wenn WooCommerce aktiv ist." msgstr "Metriken speziell für WooCommerce-Shops. Nur verfügbar, wenn WooCommerce aktiv ist."
#: src/Admin/Settings.php #: src/Admin/Settings.php
msgid "WooCommerce Products (by status and type)" msgid "WooCommerce Products (by status and type)"
@@ -223,15 +235,15 @@ msgstr "WooCommerce-Kunden (registriert, Gast)"
#: src/Admin/Settings.php #: src/Admin/Settings.php
msgid "Reset Runtime Metrics" msgid "Reset Runtime Metrics"
msgstr "Laufzeit-Metriken zuruecksetzen" msgstr "Laufzeit-Metriken zurücksetzen"
#: src/Admin/Settings.php #: src/Admin/Settings.php
msgid "Clear all accumulated runtime metric data." msgid "Clear all accumulated runtime metric data."
msgstr "Alle gesammelten Laufzeit-Metrikdaten loeschen." msgstr "Alle gesammelten Laufzeit-Metrikdaten löschen."
#: src/Admin/Settings.php #: src/Admin/Settings.php
msgid "Reset Metrics" msgid "Reset Metrics"
msgstr "Metriken zuruecksetzen" msgstr "Metriken zurücksetzen"
#: src/Admin/Settings.php #: src/Admin/Settings.php
msgid "Prometheus Configuration" msgid "Prometheus Configuration"
@@ -239,7 +251,7 @@ msgstr "Prometheus-Konfiguration"
#: src/Admin/Settings.php #: src/Admin/Settings.php
msgid "Add the following to your prometheus.yml:" msgid "Add the following to your prometheus.yml:"
msgstr "Fuegen Sie Folgendes zu Ihrer prometheus.yml hinzu:" msgstr "Fügen Sie Folgendes zu Ihrer prometheus.yml hinzu:"
#: src/Admin/Settings.php #: src/Admin/Settings.php
msgid "Endpoint Information" msgid "Endpoint Information"
@@ -255,11 +267,11 @@ msgstr "Endpunkt testen"
#: src/Admin/Settings.php #: src/Admin/Settings.php
msgid "You can test the endpoint using curl:" msgid "You can test the endpoint using curl:"
msgstr "Sie koennen den Endpunkt mit curl testen:" msgstr "Sie können den Endpunkt mit curl testen:"
#: src/Admin/Settings.php #: src/Admin/Settings.php
msgid "Available Metrics" msgid "Available Metrics"
msgstr "Verfuegbare Metriken" msgstr "Verfügbare Metriken"
#: src/Admin/Settings.php #: src/Admin/Settings.php
msgid "Metric" msgid "Metric"
@@ -295,7 +307,7 @@ msgstr "Benutzer gesamt nach Rolle"
#: src/Admin/Settings.php #: src/Admin/Settings.php
msgid "Total posts by type and status" msgid "Total posts by type and status"
msgstr "Beitraege gesamt nach Typ und Status" msgstr "Beiträge gesamt nach Typ und Status"
#: src/Admin/Settings.php #: src/Admin/Settings.php
msgid "Total comments by status" msgid "Total comments by status"
@@ -317,17 +329,21 @@ msgstr "HTTP-Anfragedauer-Verteilung"
msgid "Database queries by endpoint" msgid "Database queries by endpoint"
msgstr "Datenbank-Abfragen nach Endpunkt" msgstr "Datenbank-Abfragen nach Endpunkt"
#: src/Admin/Settings.php
msgid "Database query duration distribution (requires SAVEQUERIES)"
msgstr "Datenbank-Abfragedauer-Verteilung (erfordert SAVEQUERIES)"
#: src/Admin/Settings.php #: src/Admin/Settings.php
msgid "Scheduled cron events by hook" msgid "Scheduled cron events by hook"
msgstr "Geplante Cron-Ereignisse nach Hook" msgstr "Geplante Cron-Ereignisse nach Hook"
#: src/Admin/Settings.php #: src/Admin/Settings.php
msgid "Number of overdue cron events" msgid "Number of overdue cron events"
msgstr "Anzahl ueberfaelliger Cron-Ereignisse" msgstr "Anzahl überfälliger Cron-Ereignisse"
#: src/Admin/Settings.php #: src/Admin/Settings.php
msgid "Unix timestamp of next scheduled cron" msgid "Unix timestamp of next scheduled cron"
msgstr "Unix-Zeitstempel des naechsten geplanten Crons" msgstr "Unix-Zeitstempel des nächsten geplanten Crons"
#: src/Admin/Settings.php #: src/Admin/Settings.php
msgid "Total transients by type" msgid "Total transients by type"
@@ -351,11 +367,11 @@ msgstr "WooCommerce-Kunden nach Typ"
#: src/Admin/Settings.php #: src/Admin/Settings.php
msgid "You can add custom metrics using the wp_prometheus_collect_metrics action:" msgid "You can add custom metrics using the wp_prometheus_collect_metrics action:"
msgstr "Sie koennen benutzerdefinierte Metriken mit der wp_prometheus_collect_metrics-Aktion hinzufuegen:" msgstr "Sie können benutzerdefinierte Metriken mit der wp_prometheus_collect_metrics-Aktion hinzufügen:"
#: src/Admin/Settings.php #: src/Admin/Settings.php
msgid "Add Custom Metric" msgid "Add Custom Metric"
msgstr "Eigene Metrik hinzufuegen" msgstr "Eigene Metrik hinzufügen"
#: src/Admin/Settings.php #: src/Admin/Settings.php
msgid "Edit Custom Metric" msgid "Edit Custom Metric"
@@ -411,7 +427,7 @@ msgstr "Label-Name"
#: src/Admin/Settings.php #: src/Admin/Settings.php
msgid "Add Label" msgid "Add Label"
msgstr "Label hinzufuegen" msgstr "Label hinzufügen"
#: src/Admin/Settings.php #: src/Admin/Settings.php
msgid "Label Values" msgid "Label Values"
@@ -423,7 +439,7 @@ msgstr "Wert"
#: src/Admin/Settings.php #: src/Admin/Settings.php
msgid "Add Value Row" msgid "Add Value Row"
msgstr "Wertezeile hinzufuegen" msgstr "Wertezeile hinzufügen"
#: src/Admin/Settings.php #: src/Admin/Settings.php
msgid "Enabled" msgid "Enabled"
@@ -467,7 +483,7 @@ msgstr "Bearbeiten"
#: src/Admin/Settings.php #: src/Admin/Settings.php
msgid "Delete" msgid "Delete"
msgstr "Loeschen" msgstr "Löschen"
#: src/Admin/Settings.php #: src/Admin/Settings.php
msgid "No custom metrics defined yet." msgid "No custom metrics defined yet."
@@ -479,7 +495,7 @@ msgstr "Export / Import"
#: src/Admin/Settings.php #: src/Admin/Settings.php
msgid "Export your custom metrics configuration for backup or transfer to another site." msgid "Export your custom metrics configuration for backup or transfer to another site."
msgstr "Exportieren Sie Ihre Metriken-Konfiguration zur Sicherung oder Uebertragung auf eine andere Website." msgstr "Exportieren Sie Ihre Metriken-Konfiguration zur Sicherung oder Übertragung auf eine andere Website."
#: src/Admin/Settings.php #: src/Admin/Settings.php
msgid "Export Metrics" msgid "Export Metrics"
@@ -495,11 +511,11 @@ msgstr "Import-Optionen"
#: src/Admin/Settings.php #: src/Admin/Settings.php
msgid "Skip existing metrics" msgid "Skip existing metrics"
msgstr "Bestehende Metriken ueberspringen" msgstr "Bestehende Metriken überspringen"
#: src/Admin/Settings.php #: src/Admin/Settings.php
msgid "Overwrite existing metrics" msgid "Overwrite existing metrics"
msgstr "Bestehende Metriken ueberschreiben" msgstr "Bestehende Metriken überschreiben"
#: src/Admin/Settings.php #: src/Admin/Settings.php
msgid "Rename duplicates" msgid "Rename duplicates"
@@ -527,7 +543,7 @@ msgstr "Import-Anleitung:"
#: src/Admin/Settings.php #: src/Admin/Settings.php
msgid "Download the desired dashboard JSON file" msgid "Download the desired dashboard JSON file"
msgstr "Laden Sie die gewuenschte Dashboard-JSON-Datei herunter" msgstr "Laden Sie die gewünschte Dashboard-JSON-Datei herunter"
#: src/Admin/Settings.php #: src/Admin/Settings.php
msgid "In Grafana, go to Dashboards > Import" msgid "In Grafana, go to Dashboards > Import"
@@ -535,11 +551,11 @@ msgstr "Gehen Sie in Grafana zu Dashboards > Import"
#: src/Admin/Settings.php #: src/Admin/Settings.php
msgid "Upload the JSON file or paste its contents" msgid "Upload the JSON file or paste its contents"
msgstr "Laden Sie die JSON-Datei hoch oder fuegen Sie deren Inhalt ein" msgstr "Laden Sie die JSON-Datei hoch oder fügen Sie deren Inhalt ein"
#: src/Admin/Settings.php #: src/Admin/Settings.php
msgid "Select your Prometheus data source" msgid "Select your Prometheus data source"
msgstr "Waehlen Sie Ihre Prometheus-Datenquelle" msgstr "Wählen Sie Ihre Prometheus-Datenquelle"
#: src/Admin/Settings.php #: src/Admin/Settings.php
msgid "Click Import" msgid "Click Import"
@@ -551,7 +567,7 @@ msgstr "Metrik-Name ist erforderlich."
#: src/Metrics/CustomMetricBuilder.php #: src/Metrics/CustomMetricBuilder.php
msgid "Invalid metric name format." msgid "Invalid metric name format."
msgstr "Ungueltiges Metrik-Namensformat." msgstr "Ungültiges Metrik-Namensformat."
#: src/Metrics/CustomMetricBuilder.php #: src/Metrics/CustomMetricBuilder.php
msgid "A metric with this name already exists." msgid "A metric with this name already exists."
@@ -563,7 +579,7 @@ msgstr "Hilfetext ist erforderlich."
#: src/Metrics/CustomMetricBuilder.php #: src/Metrics/CustomMetricBuilder.php
msgid "Invalid value type." msgid "Invalid value type."
msgstr "Ungueltiger Werttyp." msgstr "Ungültiger Werttyp."
#: src/Metrics/CustomMetricBuilder.php #: src/Metrics/CustomMetricBuilder.php
msgid "Static value must be numeric." msgid "Static value must be numeric."
@@ -571,7 +587,7 @@ msgstr "Statischer Wert muss numerisch sein."
#: src/Metrics/CustomMetricBuilder.php #: src/Metrics/CustomMetricBuilder.php
msgid "Option name is required for option value type." msgid "Option name is required for option value type."
msgstr "Optionsname ist fuer den Options-Werttyp erforderlich." msgstr "Optionsname ist für den Options-Werttyp erforderlich."
#. translators: %d: Maximum number of labels #. translators: %d: Maximum number of labels
#: src/Metrics/CustomMetricBuilder.php #: src/Metrics/CustomMetricBuilder.php
@@ -580,7 +596,7 @@ msgstr "Maximal %d Labels erlaubt."
#: src/Metrics/CustomMetricBuilder.php #: src/Metrics/CustomMetricBuilder.php
msgid "Invalid label name format." msgid "Invalid label name format."
msgstr "Ungueltiges Label-Namensformat." msgstr "Ungültiges Label-Namensformat."
#. translators: %d: Maximum number of label value combinations #. translators: %d: Maximum number of label value combinations
#: src/Metrics/CustomMetricBuilder.php #: src/Metrics/CustomMetricBuilder.php
@@ -589,11 +605,11 @@ msgstr "Maximal %d Label-Wert-Kombinationen erlaubt."
#: src/Metrics/CustomMetricBuilder.php #: src/Metrics/CustomMetricBuilder.php
msgid "Invalid JSON format." msgid "Invalid JSON format."
msgstr "Ungueltiges JSON-Format." msgstr "Ungültiges JSON-Format."
#: src/Metrics/CustomMetricBuilder.php #: src/Metrics/CustomMetricBuilder.php
msgid "Invalid export format." msgid "Invalid export format."
msgstr "Ungueltiges Export-Format." msgstr "Ungültiges Export-Format."
#: src/Plugin.php #: src/Plugin.php
msgid "Settings" msgid "Settings"
@@ -602,21 +618,21 @@ msgstr "Einstellungen"
#. translators: 1: Required PHP version, 2: Current PHP version #. translators: 1: Required PHP version, 2: Current PHP version
#: wp-prometheus.php #: wp-prometheus.php
msgid "WP Prometheus requires PHP version %1$s or higher. You are running PHP %2$s." msgid "WP Prometheus requires PHP version %1$s or higher. You are running PHP %2$s."
msgstr "WP Prometheus erfordert PHP-Version %1$s oder hoeher. Sie verwenden PHP %2$s." msgstr "WP Prometheus erfordert PHP-Version %1$s oder höher. Sie verwenden PHP %2$s."
#. translators: 1: Required WordPress version, 2: Current WordPress version #. translators: 1: Required WordPress version, 2: Current WordPress version
#: wp-prometheus.php #: wp-prometheus.php
msgid "WP Prometheus requires WordPress version %1$s or higher. You are running WordPress %2$s." msgid "WP Prometheus requires WordPress version %1$s or higher. You are running WordPress %2$s."
msgstr "WP Prometheus erfordert WordPress-Version %1$s oder hoeher. Sie verwenden WordPress %2$s." msgstr "WP Prometheus erfordert WordPress-Version %1$s oder höher. Sie verwenden WordPress %2$s."
#: wp-prometheus.php #: wp-prometheus.php
msgid "WP Prometheus requires Composer dependencies to be installed. Please run \"composer install\" in the plugin directory." msgid "WP Prometheus requires Composer dependencies to be installed. Please run \"composer install\" in the plugin directory."
msgstr "WP Prometheus erfordert installierte Composer-Abhaengigkeiten. Bitte fuehren Sie \"composer install\" im Plugin-Verzeichnis aus." msgstr "WP Prometheus erfordert installierte Composer-Abhängigkeiten. Bitte führen Sie \"composer install\" im Plugin-Verzeichnis aus."
#. translators: %s: Required PHP version #. translators: %s: Required PHP version
#: wp-prometheus.php #: wp-prometheus.php
msgid "WP Prometheus requires PHP version %s or higher." msgid "WP Prometheus requires PHP version %s or higher."
msgstr "WP Prometheus erfordert PHP-Version %s oder hoeher." msgstr "WP Prometheus erfordert PHP-Version %s oder höher."
#: src/Admin/Settings.php #: src/Admin/Settings.php
msgid "Storage" msgid "Storage"
@@ -628,15 +644,15 @@ msgstr "Metriken-Speicherkonfiguration"
#: src/Admin/Settings.php #: 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." 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." msgstr "Konfigurieren Sie, wie Prometheus-Metriken gespeichert werden. Persistenter Speicher (Redis, APCu) ermöglicht es, Metriken zwischen Anfragen zu erhalten und Daten über Zeit zu aggregieren."
#: src/Admin/Settings.php #: src/Admin/Settings.php
msgid "Environment Override Active" msgid "Environment Override Active"
msgstr "Umgebungsvariablen-Ueberschreibung aktiv" msgstr "Umgebungsvariablen-Überschreibung aktiv"
#: src/Admin/Settings.php #: src/Admin/Settings.php
msgid "Storage adapter is configured via environment variable. Admin settings will be ignored." msgid "Storage adapter is configured via environment variable. Admin settings will be ignored."
msgstr "Speicher-Adapter ist ueber Umgebungsvariable konfiguriert. Admin-Einstellungen werden ignoriert." msgstr "Speicher-Adapter ist über Umgebungsvariable konfiguriert. Admin-Einstellungen werden ignoriert."
#: src/Admin/Settings.php #: src/Admin/Settings.php
msgid "Storage Fallback Active" msgid "Storage Fallback Active"
@@ -644,7 +660,7 @@ msgstr "Speicher-Fallback aktiv"
#: src/Admin/Settings.php #: src/Admin/Settings.php
msgid "Falling back to In-Memory storage." msgid "Falling back to In-Memory storage."
msgstr "Faellt zurueck auf In-Memory-Speicher." msgstr "Fällt zurück auf In-Memory-Speicher."
#: src/Admin/Settings.php #: src/Admin/Settings.php
msgid "Current Status:" msgid "Current Status:"
@@ -661,11 +677,11 @@ msgstr "Speicher-Adapter"
#: src/Admin/Settings.php #: src/Admin/Settings.php
msgid "unavailable" msgid "unavailable"
msgstr "nicht verfuegbar" msgstr "nicht verfügbar"
#: src/Admin/Settings.php #: src/Admin/Settings.php
msgid "Select the storage backend for metrics. Redis and APCu require their respective PHP extensions." 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." msgstr "Wählen Sie das Speicher-Backend für Metriken. Redis und APCu erfordern ihre jeweiligen PHP-Erweiterungen."
#: src/Admin/Settings.php #: src/Admin/Settings.php
msgid "Redis Configuration" msgid "Redis Configuration"
@@ -678,7 +694,7 @@ msgstr "Host"
#. translators: %s: Environment variable name #. translators: %s: Environment variable name
#: src/Admin/Settings.php #: src/Admin/Settings.php
msgid "Can be overridden with %s environment variable." msgid "Can be overridden with %s environment variable."
msgstr "Kann mit Umgebungsvariable %s ueberschrieben werden." msgstr "Kann mit Umgebungsvariable %s überschrieben werden."
#: src/Admin/Settings.php #: src/Admin/Settings.php
msgid "Port" msgid "Port"
@@ -699,15 +715,15 @@ msgstr "Datenbank"
#. translators: %s: Environment variable name #. translators: %s: Environment variable name
#: src/Admin/Settings.php #: src/Admin/Settings.php
msgid "Redis database index (0-15). Can be overridden with %s." msgid "Redis database index (0-15). Can be overridden with %s."
msgstr "Redis-Datenbankindex (0-15). Kann mit %s ueberschrieben werden." msgstr "Redis-Datenbankindex (0-15). Kann mit %s überschrieben werden."
#: src/Admin/Settings.php #: src/Admin/Settings.php
msgid "Key Prefix" msgid "Key Prefix"
msgstr "Schluessel-Praefix" msgstr "Schlüssel-Präfix"
#: src/Admin/Settings.php #: src/Admin/Settings.php
msgid "Prefix for Redis keys. Useful when sharing Redis with other applications." 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." msgstr "Präfix für Redis-Schlüssel. Nützlich bei gemeinsamer Redis-Nutzung mit anderen Anwendungen."
#: src/Admin/Settings.php #: src/Admin/Settings.php
msgid "APCu Configuration" msgid "APCu Configuration"
@@ -716,7 +732,7 @@ msgstr "APCu-Konfiguration"
#. translators: %s: Environment variable name #. translators: %s: Environment variable name
#: src/Admin/Settings.php #: src/Admin/Settings.php
msgid "Prefix for APCu keys. Can be overridden with %s." msgid "Prefix for APCu keys. Can be overridden with %s."
msgstr "Praefix fuer APCu-Schluessel. Kann mit %s ueberschrieben werden." msgstr "Präfix für APCu-Schlüssel. Kann mit %s überschrieben werden."
#: src/Admin/Settings.php #: src/Admin/Settings.php
msgid "Save Storage Settings" msgid "Save Storage Settings"
@@ -732,7 +748,7 @@ msgstr "Umgebungsvariablen"
#: src/Admin/Settings.php #: src/Admin/Settings.php
msgid "For Docker or containerized environments, you can configure storage using environment variables. These take precedence over admin settings." 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." msgstr "Für Docker- oder Container-Umgebungen können Sie den Speicher über Umgebungsvariablen konfigurieren. Diese haben Vorrang vor Admin-Einstellungen."
#: src/Admin/Settings.php #: src/Admin/Settings.php
msgid "Variable" msgid "Variable"
@@ -764,11 +780,23 @@ msgstr "Redis-Datenbankindex"
#: src/Admin/Settings.php #: src/Admin/Settings.php
msgid "Redis key prefix" msgid "Redis key prefix"
msgstr "Redis-Schluessel-Praefix" msgstr "Redis-Schlüssel-Präfix"
#: src/Admin/Settings.php #: src/Admin/Settings.php
msgid "APCu key prefix" msgid "APCu key prefix"
msgstr "APCu-Schluessel-Praefix" msgstr "APCu-Schlüssel-Präfix"
#: src/Admin/Settings.php
msgid "License server URL"
msgstr "Lizenz-Server-URL"
#: src/Admin/Settings.php
msgid "License key"
msgstr "Lizenzschlüssel"
#: src/Admin/Settings.php
msgid "License server shared secret"
msgstr "Gemeinsames Geheimnis des Lizenz-Servers"
#: src/Admin/Settings.php #: src/Admin/Settings.php
msgid "Docker Compose Example" msgid "Docker Compose Example"
@@ -780,11 +808,11 @@ msgstr "Zugriff verweigert."
#: src/Admin/Settings.php #: src/Admin/Settings.php
msgid "Storage adapter is configured via environment variable and cannot be changed." msgid "Storage adapter is configured via environment variable and cannot be changed."
msgstr "Speicher-Adapter ist ueber Umgebungsvariable konfiguriert und kann nicht geaendert werden." msgstr "Speicher-Adapter ist über Umgebungsvariable konfiguriert und kann nicht geändert werden."
#: src/Admin/Settings.php #: src/Admin/Settings.php
msgid "Invalid storage adapter." msgid "Invalid storage adapter."
msgstr "Ungueltiger Speicher-Adapter." msgstr "Ungültiger Speicher-Adapter."
#: src/Admin/Settings.php #: src/Admin/Settings.php
msgid "Storage settings saved successfully." msgid "Storage settings saved successfully."
@@ -840,7 +868,7 @@ msgstr "APCu-Fehler: %s"
#: src/Metrics/StorageFactory.php #: src/Metrics/StorageFactory.php
msgid "In-Memory storage is always available." msgid "In-Memory storage is always available."
msgstr "In-Memory-Speicher ist immer verfuegbar." msgstr "In-Memory-Speicher ist immer verfügbar."
#: src/Metrics/StorageFactory.php #: src/Metrics/StorageFactory.php
msgid "Unknown storage adapter." msgid "Unknown storage adapter."
@@ -865,7 +893,7 @@ msgstr "Redis-Ping fehlgeschlagen."
#: src/Metrics/StorageFactory.php #: src/Metrics/StorageFactory.php
msgid "APCu is installed but not enabled. Check your php.ini settings." 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." msgstr "APCu ist installiert, aber nicht aktiviert. Prüfen Sie Ihre php.ini-Einstellungen."
#: src/Metrics/StorageFactory.php #: src/Metrics/StorageFactory.php
msgid "APCu store operation failed." msgid "APCu store operation failed."
@@ -878,47 +906,47 @@ msgstr "APCu funktioniert. Speicher: %s belegt."
#: src/Metrics/StorageFactory.php #: src/Metrics/StorageFactory.php
msgid "APCu fetch operation returned unexpected value." msgid "APCu fetch operation returned unexpected value."
msgstr "APCu-Abrufoperation hat unerwarteten Wert zurueckgegeben." msgstr "APCu-Abrufoperation hat unerwarteten Wert zurückgegeben."
#: src/Admin/Settings.php #: src/Admin/Settings.php
msgid "Early Mode" msgid "Early Mode"
msgstr "Fruehzeitiger Modus" msgstr "Frühzeitiger Modus"
#: src/Admin/Settings.php #: 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." 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." msgstr "Der frühzeitige Modus fängt /metrics-Anfragen vor der vollständigen WordPress-Initialisierung ab. Dies verhindert Speichererschöpfungsprobleme, die durch einige Plugins verursacht werden (z.B. Twig-basierte Themes/Plugins), deaktiviert jedoch den wp_prometheus_collect_metrics-Hook für benutzerdefinierte Metriken."
#: src/Admin/Settings.php #: src/Admin/Settings.php
msgid "Early mode is configured via WP_PROMETHEUS_DISABLE_EARLY_MODE environment variable. Admin settings will be ignored." 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." msgstr "Der frühzeitige Modus ist über die Umgebungsvariable WP_PROMETHEUS_DISABLE_EARLY_MODE konfiguriert. Admin-Einstellungen werden ignoriert."
#: src/Admin/Settings.php #: src/Admin/Settings.php
msgid "Disable Early Mode" msgid "Disable Early Mode"
msgstr "Fruehzeitigen Modus deaktivieren" msgstr "Frühzeitigen Modus deaktivieren"
#: src/Admin/Settings.php #: src/Admin/Settings.php
msgid "Disable early metrics interception" msgid "Disable early metrics interception"
msgstr "Fruehzeitige Metriken-Abfangung deaktivieren" msgstr "Frühzeitige Metriken-Abfangung deaktivieren"
#: src/Admin/Settings.php #: 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." 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." msgstr "Wenn deaktiviert, werden Metriken über das normale WordPress-Template-Laden erfasst. Dies aktiviert den wp_prometheus_collect_metrics-Hook für benutzerdefinierte Metriken, kann jedoch Probleme mit einigen Plugins verursachen."
#: src/Admin/Settings.php #: src/Admin/Settings.php
msgid "Early mode is active (this request was served via early interception)" msgid "Early mode is active (this request was served via early interception)"
msgstr "Fruehzeitiger Modus ist aktiv (diese Anfrage wurde ueber fruehzeitige Abfangung verarbeitet)" msgstr "Frühzeitiger Modus ist aktiv (diese Anfrage wurde über frühzeitige Abfangung verarbeitet)"
#: src/Admin/Settings.php #: src/Admin/Settings.php
msgid "Early mode is disabled" msgid "Early mode is disabled"
msgstr "Fruehzeitiger Modus ist deaktiviert" msgstr "Frühzeitiger Modus ist deaktiviert"
#: src/Admin/Settings.php #: src/Admin/Settings.php
msgid "Early mode is enabled (active for /metrics requests)" msgid "Early mode is enabled (active for /metrics requests)"
msgstr "Fruehzeitiger Modus ist aktiviert (aktiv fuer /metrics-Anfragen)" msgstr "Frühzeitiger Modus ist aktiviert (aktiv für /metrics-Anfragen)"
#: src/Admin/Settings.php #: src/Admin/Settings.php
msgid "Clear all accumulated runtime metric data (HTTP requests, database queries). This is useful for testing or starting fresh." msgid "Clear all accumulated runtime metric data (HTTP requests, database queries). This is useful for testing or starting fresh."
msgstr "Alle gesammelten Laufzeit-Metrikdaten loeschen (HTTP-Anfragen, Datenbank-Abfragen). Dies ist nuetzlich zum Testen oder fuer einen Neuanfang." msgstr "Alle gesammelten Laufzeit-Metrikdaten löschen (HTTP-Anfragen, Datenbank-Abfragen). Dies ist nützlich zum Testen oder für einen Neuanfang."
#: src/Admin/Settings.php #: src/Admin/Settings.php
msgid "Endpoint" msgid "Endpoint"
@@ -942,8 +970,54 @@ msgstr "Laufzeit-Metriken Verwaltung"
#: src/Admin/Settings.php #: src/Admin/Settings.php
msgid "Runtime metrics track HTTP requests and database queries across requests. Use this section to manage accumulated data." 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." msgstr "Laufzeit-Metriken erfassen HTTP-Anfragen und Datenbank-Abfragen über mehrere Anfragen hinweg. Verwenden Sie diesen Bereich zur Verwaltung der gesammelten Daten."
#: src/Admin/Settings.php #: src/Admin/Settings.php
msgid "Reset Data" msgid "Reset Data"
msgstr "Daten zuruecksetzen" msgstr "Daten zurücksetzen"
#: 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 verfügbar."
#: 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 für das gewünschte 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 fügen Sie den Inhalt ein."
#: src/Admin/Settings.php
msgid "Select your Prometheus data source when prompted."
msgstr "Wählen 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

@@ -98,6 +98,18 @@ msgstr ""
msgid "Leave empty to keep existing." msgid "Leave empty to keep existing."
msgstr "" msgstr ""
#: src/Admin/Settings.php
msgid "Overridden by WP_PROMETHEUS_LICENSE_SERVER_URL environment variable."
msgstr ""
#: src/Admin/Settings.php
msgid "Overridden by WP_PROMETHEUS_LICENSE_KEY environment variable."
msgstr ""
#: src/Admin/Settings.php
msgid "Overridden by WP_PROMETHEUS_LICENSE_SERVER_SECRET environment variable."
msgstr ""
#: src/Admin/Settings.php #: src/Admin/Settings.php
msgid "Save License Settings" msgid "Save License Settings"
msgstr "" msgstr ""
@@ -314,6 +326,10 @@ msgstr ""
msgid "Database queries by endpoint" msgid "Database queries by endpoint"
msgstr "" msgstr ""
#: src/Admin/Settings.php
msgid "Database query duration distribution (requires SAVEQUERIES)"
msgstr ""
#: src/Admin/Settings.php #: src/Admin/Settings.php
msgid "Scheduled cron events by hook" msgid "Scheduled cron events by hook"
msgstr "" msgstr ""
@@ -767,6 +783,18 @@ msgstr ""
msgid "APCu key prefix" msgid "APCu key prefix"
msgstr "" msgstr ""
#: src/Admin/Settings.php
msgid "License server URL"
msgstr ""
#: src/Admin/Settings.php
msgid "License key"
msgstr ""
#: src/Admin/Settings.php
msgid "License server shared secret"
msgstr ""
#: src/Admin/Settings.php #: src/Admin/Settings.php
msgid "Docker Compose Example" msgid "Docker Compose Example"
msgstr "" msgstr ""
@@ -944,3 +972,49 @@ msgstr ""
#: src/Admin/Settings.php #: src/Admin/Settings.php
msgid "Reset Data" msgid "Reset Data"
msgstr "" msgstr ""
#: src/Admin/Settings.php
msgid "Extension"
msgstr ""
#. translators: %s: Plugin name
#: src/Admin/Settings.php
msgid "Provided by: %s"
msgstr ""
#: src/Admin/Settings.php
msgid "No dashboards available."
msgstr ""
#: src/Admin/Settings.php
msgid "Pre-built dashboards for visualizing your WordPress metrics in Grafana."
msgstr ""
#: src/Admin/Settings.php
msgid "Installation Instructions"
msgstr ""
#: src/Admin/Settings.php
msgid "Download the JSON file for your desired dashboard."
msgstr ""
#: src/Admin/Settings.php
msgid "In Grafana, go to Dashboards → Import."
msgstr ""
#: src/Admin/Settings.php
msgid "Upload the JSON file or paste its contents."
msgstr ""
#: src/Admin/Settings.php
msgid "Select your Prometheus data source when prompted."
msgstr ""
#: src/Admin/Settings.php
msgid "Click Import to create the dashboard."
msgstr ""
#. translators: %s: Metrics URL
#: src/Admin/Settings.php
msgid "Make sure your Prometheus instance is configured to scrape %s with the correct authentication token."
msgstr ""

25
phpunit.xml Normal file
View File

@@ -0,0 +1,25 @@
<?xml version="1.0" encoding="UTF-8"?>
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"
bootstrap="tests/bootstrap.php"
colors="true"
cacheDirectory=".phpunit.cache"
executionOrder="depends,defects"
beStrictAboutOutputDuringTests="true"
failOnRisky="true"
failOnWarning="true">
<testsuites>
<testsuite name="unit">
<directory>tests/Unit</directory>
</testsuite>
</testsuites>
<source>
<include>
<directory>src</directory>
</include>
<exclude>
<file>src/index.php</file>
</exclude>
</source>
</phpunit>

View File

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

View File

@@ -49,15 +49,6 @@ class Settings {
* Constructor. * Constructor.
*/ */
public function __construct() { public function __construct() {
$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' ),
);
$this->metric_builder = new CustomMetricBuilder(); $this->metric_builder = new CustomMetricBuilder();
$this->dashboard_provider = new DashboardProvider(); $this->dashboard_provider = new DashboardProvider();
@@ -76,14 +67,37 @@ class Settings {
add_action( 'wp_ajax_wp_prometheus_test_storage', array( $this, 'ajax_test_storage' ) ); add_action( 'wp_ajax_wp_prometheus_test_storage', array( $this, 'ajax_test_storage' ) );
} }
/**
* Get available tabs.
*
* Lazily initializes tab labels to avoid triggering textdomain loading
* before the 'init' action (required since WordPress 6.7).
*
* @return array
*/
private function get_tabs(): array {
if ( empty( $this->tabs ) ) {
$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' ),
);
}
return $this->tabs;
}
/** /**
* Get current tab. * Get current tab.
* *
* @return string * @return string
*/ */
private function get_current_tab(): string { private function get_current_tab(): string {
$tabs = $this->get_tabs();
$tab = isset( $_GET['tab'] ) ? sanitize_key( $_GET['tab'] ) : 'license'; $tab = isset( $_GET['tab'] ) ? sanitize_key( $_GET['tab'] ) : 'license';
return array_key_exists( $tab, $this->tabs ) ? $tab : 'license'; return array_key_exists( $tab, $tabs ) ? $tab : 'license';
} }
/** /**
@@ -107,18 +121,20 @@ class Settings {
* @return void * @return void
*/ */
public function register_settings(): void { public function register_settings(): void {
// Register settings for metrics tab. // Register settings for endpoint sub-tab.
register_setting( 'wp_prometheus_metrics_settings', 'wp_prometheus_auth_token', array( register_setting( 'wp_prometheus_endpoint_settings', 'wp_prometheus_auth_token', array(
'type' => 'string', 'type' => 'string',
'sanitize_callback' => 'sanitize_text_field', 'sanitize_callback' => 'sanitize_text_field',
) ); ) );
register_setting( 'wp_prometheus_metrics_settings', 'wp_prometheus_enabled_metrics', array( // Register settings for selection sub-tab.
register_setting( 'wp_prometheus_selection_settings', 'wp_prometheus_enabled_metrics', array(
'type' => 'array', 'type' => 'array',
'sanitize_callback' => array( $this, 'sanitize_metrics' ), 'sanitize_callback' => array( $this, 'sanitize_metrics' ),
) ); ) );
register_setting( 'wp_prometheus_metrics_settings', 'wp_prometheus_disable_early_mode', array( // Register settings for advanced sub-tab.
register_setting( 'wp_prometheus_advanced_settings', 'wp_prometheus_isolated_mode', array(
'type' => 'boolean', 'type' => 'boolean',
'sanitize_callback' => 'rest_sanitize_boolean', 'sanitize_callback' => 'rest_sanitize_boolean',
'default' => false, 'default' => false,
@@ -266,7 +282,7 @@ class Settings {
?> ?>
<nav class="nav-tab-wrapper wp-clearfix"> <nav class="nav-tab-wrapper wp-clearfix">
<?php <?php
foreach ( $this->tabs as $tab_id => $tab_name ) { foreach ( $this->get_tabs() as $tab_id => $tab_name ) {
$tab_url = add_query_arg( $tab_url = add_query_arg(
array( array(
'page' => 'wp-prometheus', 'page' => 'wp-prometheus',
@@ -295,6 +311,9 @@ class Settings {
private function render_license_tab(): void { private function render_license_tab(): void {
$license_key = LicenseManager::get_license_key(); $license_key = LicenseManager::get_license_key();
$server_url = LicenseManager::get_server_url(); $server_url = LicenseManager::get_server_url();
$env_server_url = LicenseManager::is_env_override( 'server_url' );
$env_license_key = LicenseManager::is_env_override( 'license_key' );
$env_server_secret = LicenseManager::is_env_override( 'server_secret' );
$license_status = LicenseManager::get_cached_status(); $license_status = LicenseManager::get_cached_status();
$license_data = LicenseManager::get_cached_data(); $license_data = LicenseManager::get_cached_data();
$last_check = LicenseManager::get_last_check(); $last_check = LicenseManager::get_last_check();
@@ -359,7 +378,11 @@ class Settings {
<td> <td>
<input type="url" name="license_server_url" id="license_server_url" <input type="url" name="license_server_url" id="license_server_url"
value="<?php echo esc_attr( $server_url ); ?>" value="<?php echo esc_attr( $server_url ); ?>"
class="regular-text" placeholder="https://example.com"> class="regular-text" placeholder="https://example.com"
<?php echo $env_server_url ? 'disabled="disabled"' : ''; ?>>
<?php if ( $env_server_url ) : ?>
<p class="description"><?php esc_html_e( 'Overridden by WP_PROMETHEUS_LICENSE_SERVER_URL environment variable.', 'wp-prometheus' ); ?></p>
<?php endif; ?>
</td> </td>
</tr> </tr>
<tr> <tr>
@@ -369,7 +392,11 @@ class Settings {
<td> <td>
<input type="text" name="license_key" id="license_key" <input type="text" name="license_key" id="license_key"
value="<?php echo esc_attr( $license_key ); ?>" value="<?php echo esc_attr( $license_key ); ?>"
class="regular-text" placeholder="XXXX-XXXX-XXXX-XXXX"> class="regular-text" placeholder="XXXX-XXXX-XXXX-XXXX"
<?php echo $env_license_key ? 'disabled="disabled"' : ''; ?>>
<?php if ( $env_license_key ) : ?>
<p class="description"><?php esc_html_e( 'Overridden by WP_PROMETHEUS_LICENSE_KEY environment variable.', 'wp-prometheus' ); ?></p>
<?php endif; ?>
</td> </td>
</tr> </tr>
<tr> <tr>
@@ -378,8 +405,13 @@ class Settings {
</th> </th>
<td> <td>
<input type="password" name="license_server_secret" id="license_server_secret" <input type="password" name="license_server_secret" id="license_server_secret"
value="" class="regular-text" placeholder="<?php echo esc_attr( ! empty( LicenseManager::get_server_secret() ) ? '••••••••••••••••' : '' ); ?>"> value="" class="regular-text" placeholder="<?php echo esc_attr( ! empty( LicenseManager::get_server_secret() ) ? '••••••••••••••••' : '' ); ?>"
<?php echo $env_server_secret ? 'disabled="disabled"' : ''; ?>>
<?php if ( $env_server_secret ) : ?>
<p class="description"><?php esc_html_e( 'Overridden by WP_PROMETHEUS_LICENSE_SERVER_SECRET environment variable.', 'wp-prometheus' ); ?></p>
<?php else : ?>
<p class="description"><?php esc_html_e( 'Leave empty to keep existing.', 'wp-prometheus' ); ?></p> <p class="description"><?php esc_html_e( 'Leave empty to keep existing.', 'wp-prometheus' ); ?></p>
<?php endif; ?>
</td> </td>
</tr> </tr>
</table> </table>
@@ -484,7 +516,7 @@ class Settings {
private function render_metrics_endpoint_subtab(): void { private function render_metrics_endpoint_subtab(): void {
?> ?>
<form method="post" action="options.php"> <form method="post" action="options.php">
<?php settings_fields( 'wp_prometheus_metrics_settings' ); ?> <?php settings_fields( 'wp_prometheus_endpoint_settings' ); ?>
<h3><?php esc_html_e( 'Authentication', 'wp-prometheus' ); ?></h3> <h3><?php esc_html_e( 'Authentication', 'wp-prometheus' ); ?></h3>
<p class="description"><?php esc_html_e( 'Configure authentication for the /metrics endpoint.', 'wp-prometheus' ); ?></p> <p class="description"><?php esc_html_e( 'Configure authentication for the /metrics endpoint.', 'wp-prometheus' ); ?></p>
@@ -513,7 +545,7 @@ class Settings {
private function render_metrics_selection_subtab(): void { private function render_metrics_selection_subtab(): void {
?> ?>
<form method="post" action="options.php"> <form method="post" action="options.php">
<?php settings_fields( 'wp_prometheus_metrics_settings' ); ?> <?php settings_fields( 'wp_prometheus_selection_settings' ); ?>
<h3><?php esc_html_e( 'Enabled Metrics', 'wp-prometheus' ); ?></h3> <h3><?php esc_html_e( 'Enabled Metrics', 'wp-prometheus' ); ?></h3>
<p class="description"><?php esc_html_e( 'Select which metrics to expose on the /metrics endpoint.', 'wp-prometheus' ); ?></p> <p class="description"><?php esc_html_e( 'Select which metrics to expose on the /metrics endpoint.', 'wp-prometheus' ); ?></p>
@@ -566,57 +598,93 @@ class Settings {
* @return void * @return void
*/ */
private function render_metrics_advanced_subtab(): void { private function render_metrics_advanced_subtab(): void {
$disabled = get_option( 'wp_prometheus_disable_early_mode', false ); $isolated_mode = get_option( 'wp_prometheus_isolated_mode', false );
$env_override = false !== getenv( 'WP_PROMETHEUS_DISABLE_EARLY_MODE' ); $env_override = false !== getenv( 'WP_PROMETHEUS_ISOLATED_MODE' );
$early_active = defined( 'WP_PROMETHEUS_EARLY_METRICS' ) && WP_PROMETHEUS_EARLY_METRICS; $is_metrics_request = defined( 'WP_PROMETHEUS_METRICS_REQUEST' ) && WP_PROMETHEUS_METRICS_REQUEST;
$is_isolated = defined( 'WP_PROMETHEUS_ISOLATED_MODE' ) && WP_PROMETHEUS_ISOLATED_MODE;
?> ?>
<form method="post" action="options.php"> <form method="post" action="options.php">
<?php settings_fields( 'wp_prometheus_metrics_settings' ); ?> <?php settings_fields( 'wp_prometheus_advanced_settings' ); ?>
<h3><?php esc_html_e( 'Early Mode', 'wp-prometheus' ); ?></h3> <h3><?php esc_html_e( 'Metrics Collection Mode', 'wp-prometheus' ); ?></h3>
<p class="description">
<?php esc_html_e( 'Early mode intercepts /metrics requests before full WordPress initialization. This prevents memory exhaustion issues caused by some plugins (e.g., Twig-based themes/plugins) but disables the wp_prometheus_collect_metrics hook for custom metrics.', 'wp-prometheus' ); ?> <div class="notice notice-info inline" style="padding: 12px; margin: 15px 0;">
</p> <p><strong><?php esc_html_e( 'Safe Mode (Default)', 'wp-prometheus' ); ?></strong></p>
<p><?php esc_html_e( 'Content filters are removed early to prevent memory issues with Twig-based plugins, but WordPress loads normally. Third-party plugins can add custom metrics via the wp_prometheus_collect_metrics hook.', 'wp-prometheus' ); ?></p>
</div>
<?php if ( $env_override ) : ?> <?php if ( $env_override ) : ?>
<div class="notice notice-info inline" style="padding: 12px; margin: 15px 0;"> <div class="notice notice-warning inline" style="padding: 12px; margin: 15px 0;">
<strong><?php esc_html_e( 'Environment Override Active', 'wp-prometheus' ); ?></strong> <strong><?php esc_html_e( 'Environment Override Active', 'wp-prometheus' ); ?></strong>
<p><?php esc_html_e( 'Early mode is configured via WP_PROMETHEUS_DISABLE_EARLY_MODE environment variable. Admin settings will be ignored.', 'wp-prometheus' ); ?></p> <p><?php esc_html_e( 'Mode is configured via WP_PROMETHEUS_ISOLATED_MODE environment variable. Admin settings will be ignored.', 'wp-prometheus' ); ?></p>
</div> </div>
<?php endif; ?> <?php endif; ?>
<table class="form-table" role="presentation"> <table class="form-table" role="presentation">
<tr> <tr>
<th scope="row"><?php esc_html_e( 'Disable Early Mode', 'wp-prometheus' ); ?></th> <th scope="row"><?php esc_html_e( 'Isolated Mode', 'wp-prometheus' ); ?></th>
<td> <td>
<label> <label>
<input type="checkbox" name="wp_prometheus_disable_early_mode" value="1" <input type="checkbox" name="wp_prometheus_isolated_mode" value="1"
<?php checked( $disabled ); ?> <?php checked( $isolated_mode ); ?>
<?php disabled( $env_override ); ?>> <?php disabled( $env_override ); ?>>
<?php esc_html_e( 'Disable early metrics interception', 'wp-prometheus' ); ?> <?php esc_html_e( 'Enable isolated mode', 'wp-prometheus' ); ?>
</label> </label>
<p class="description"> <p class="description">
<?php esc_html_e( 'When disabled, metrics are collected through normal WordPress template loading. This enables the wp_prometheus_collect_metrics hook for custom metrics but may cause issues with some plugins.', 'wp-prometheus' ); ?> <?php esc_html_e( 'Isolated mode outputs metrics immediately before other plugins fully load. This provides maximum isolation but disables the wp_prometheus_collect_metrics hook. Use this only if you experience issues with Safe Mode.', 'wp-prometheus' ); ?>
</p> </p>
</td> </td>
</tr> </tr>
<tr> <tr>
<th scope="row"><?php esc_html_e( 'Current Status', 'wp-prometheus' ); ?></th> <th scope="row"><?php esc_html_e( 'Current Status', 'wp-prometheus' ); ?></th>
<td> <td>
<?php if ( $early_active ) : ?> <?php if ( $is_isolated ) : ?>
<span class="dashicons dashicons-lock" style="color: orange;"></span>
<?php esc_html_e( 'Isolated mode active - custom hooks are disabled', 'wp-prometheus' ); ?>
<?php elseif ( $is_metrics_request ) : ?>
<span class="dashicons dashicons-yes-alt" style="color: green;"></span> <span class="dashicons dashicons-yes-alt" style="color: green;"></span>
<?php esc_html_e( 'Early mode is active (this request was served via early interception)', 'wp-prometheus' ); ?> <?php esc_html_e( 'Safe mode active - custom hooks enabled with filter protection', 'wp-prometheus' ); ?>
<?php elseif ( $disabled || $env_override ) : ?> <?php elseif ( $isolated_mode ) : ?>
<span class="dashicons dashicons-dismiss" style="color: gray;"></span> <span class="dashicons dashicons-lock" style="color: orange;"></span>
<?php esc_html_e( 'Early mode is disabled', 'wp-prometheus' ); ?> <?php esc_html_e( 'Isolated mode enabled (active for /metrics requests)', 'wp-prometheus' ); ?>
<?php else : ?> <?php else : ?>
<span class="dashicons dashicons-yes-alt" style="color: green;"></span> <span class="dashicons dashicons-yes-alt" style="color: green;"></span>
<?php esc_html_e( 'Early mode is enabled (active for /metrics requests)', 'wp-prometheus' ); ?> <?php esc_html_e( 'Safe mode enabled (default) - custom hooks with filter protection', 'wp-prometheus' ); ?>
<?php endif; ?> <?php endif; ?>
</td> </td>
</tr> </tr>
</table> </table>
<hr style="margin: 20px 0;">
<h4><?php esc_html_e( 'Mode Comparison', 'wp-prometheus' ); ?></h4>
<table class="widefat striped" style="max-width: 700px;">
<thead>
<tr>
<th><?php esc_html_e( 'Feature', 'wp-prometheus' ); ?></th>
<th><?php esc_html_e( 'Safe Mode', 'wp-prometheus' ); ?></th>
<th><?php esc_html_e( 'Isolated Mode', 'wp-prometheus' ); ?></th>
</tr>
</thead>
<tbody>
<tr>
<td><?php esc_html_e( 'Custom metrics hook', 'wp-prometheus' ); ?></td>
<td><span class="dashicons dashicons-yes" style="color: green;"></span></td>
<td><span class="dashicons dashicons-no" style="color: red;"></span></td>
</tr>
<tr>
<td><?php esc_html_e( 'Plugin compatibility', 'wp-prometheus' ); ?></td>
<td><?php esc_html_e( 'High', 'wp-prometheus' ); ?></td>
<td><?php esc_html_e( 'Maximum', 'wp-prometheus' ); ?></td>
</tr>
<tr>
<td><?php esc_html_e( 'Memory usage', 'wp-prometheus' ); ?></td>
<td><?php esc_html_e( 'Normal', 'wp-prometheus' ); ?></td>
<td><?php esc_html_e( 'Minimal', 'wp-prometheus' ); ?></td>
</tr>
</tbody>
</table>
<?php submit_button(); ?> <?php submit_button(); ?>
</form> </form>
<?php <?php
@@ -878,6 +946,21 @@ class Settings {
<td><?php esc_html_e( 'APCu key prefix', 'wp-prometheus' ); ?></td> <td><?php esc_html_e( 'APCu key prefix', 'wp-prometheus' ); ?></td>
<td><code>wp_prom</code></td> <td><code>wp_prom</code></td>
</tr> </tr>
<tr>
<td><code>WP_PROMETHEUS_LICENSE_SERVER_URL</code></td>
<td><?php esc_html_e( 'License server URL', 'wp-prometheus' ); ?></td>
<td><code>https://license.example.com</code></td>
</tr>
<tr>
<td><code>WP_PROMETHEUS_LICENSE_KEY</code></td>
<td><?php esc_html_e( 'License key', 'wp-prometheus' ); ?></td>
<td><code>XXXX-XXXX-XXXX-XXXX</code></td>
</tr>
<tr>
<td><code>WP_PROMETHEUS_LICENSE_SERVER_SECRET</code></td>
<td><?php esc_html_e( 'License server shared secret', 'wp-prometheus' ); ?></td>
<td><code>my-shared-secret</code></td>
</tr>
</tbody> </tbody>
</table> </table>
@@ -889,6 +972,9 @@ class Settings {
WP_PROMETHEUS_STORAGE_ADAPTER: redis WP_PROMETHEUS_STORAGE_ADAPTER: redis
WP_PROMETHEUS_REDIS_HOST: redis WP_PROMETHEUS_REDIS_HOST: redis
WP_PROMETHEUS_REDIS_PORT: 6379 WP_PROMETHEUS_REDIS_PORT: 6379
WP_PROMETHEUS_LICENSE_SERVER_URL: https://license.example.com
WP_PROMETHEUS_LICENSE_KEY: XXXX-XXXX-XXXX-XXXX
WP_PROMETHEUS_LICENSE_SERVER_SECRET: my-shared-secret
depends_on: depends_on:
- redis - redis
@@ -1176,20 +1262,45 @@ class Settings {
<h2><?php esc_html_e( 'Grafana Dashboard Templates', 'wp-prometheus' ); ?></h2> <h2><?php esc_html_e( 'Grafana Dashboard Templates', 'wp-prometheus' ); ?></h2>
<p class="description"><?php esc_html_e( 'Pre-built dashboards for visualizing your WordPress metrics in Grafana.', 'wp-prometheus' ); ?></p> <p class="description"><?php esc_html_e( 'Pre-built dashboards for visualizing your WordPress metrics in Grafana.', 'wp-prometheus' ); ?></p>
<?php if ( empty( $dashboards ) ) : ?>
<p class="description"><?php esc_html_e( 'No dashboards available.', 'wp-prometheus' ); ?></p>
<?php else : ?>
<div class="wp-prometheus-dashboard-grid"> <div class="wp-prometheus-dashboard-grid">
<?php foreach ( $dashboards as $slug => $dashboard ) : ?> <?php
<div class="wp-prometheus-dashboard-card"> foreach ( $dashboards as $slug => $dashboard ) :
$is_third_party = $this->dashboard_provider->is_third_party( $slug );
$plugin_name = $this->dashboard_provider->get_plugin_name( $slug );
$card_class = 'wp-prometheus-dashboard-card' . ( $is_third_party ? ' third-party' : '' );
?>
<div class="<?php echo esc_attr( $card_class ); ?>">
<?php if ( $is_third_party ) : ?>
<span class="dashboard-badge"><?php esc_html_e( 'Extension', 'wp-prometheus' ); ?></span>
<?php endif; ?>
<div class="dashboard-icon"> <div class="dashboard-icon">
<span class="dashicons <?php echo esc_attr( $dashboard['icon'] ); ?>"></span> <span class="dashicons <?php echo esc_attr( $dashboard['icon'] ); ?>"></span>
</div> </div>
<h3><?php echo esc_html( $dashboard['title'] ); ?></h3> <h3><?php echo esc_html( $dashboard['title'] ); ?></h3>
<p><?php echo esc_html( $dashboard['description'] ); ?></p> <p><?php echo esc_html( $dashboard['description'] ); ?></p>
<?php if ( $is_third_party && $plugin_name ) : ?>
<p class="dashboard-plugin">
<small>
<?php
printf(
/* translators: %s: Plugin name */
esc_html__( 'Provided by: %s', 'wp-prometheus' ),
esc_html( $plugin_name )
);
?>
</small>
</p>
<?php endif; ?>
<button type="button" class="button button-primary download-dashboard" data-slug="<?php echo esc_attr( $slug ); ?>"> <button type="button" class="button button-primary download-dashboard" data-slug="<?php echo esc_attr( $slug ); ?>">
<?php esc_html_e( 'Download', 'wp-prometheus' ); ?> <?php esc_html_e( 'Download', 'wp-prometheus' ); ?>
</button> </button>
</div> </div>
<?php endforeach; ?> <?php endforeach; ?>
</div> </div>
<?php endif; ?>
<hr style="margin: 30px 0;"> <hr style="margin: 30px 0;">
@@ -1302,6 +1413,11 @@ class Settings {
<td><?php esc_html_e( 'Counter', 'wp-prometheus' ); ?></td> <td><?php esc_html_e( 'Counter', 'wp-prometheus' ); ?></td>
<td><?php esc_html_e( 'Database queries by endpoint', 'wp-prometheus' ); ?></td> <td><?php esc_html_e( 'Database queries by endpoint', 'wp-prometheus' ); ?></td>
</tr> </tr>
<tr>
<td><code>wordpress_db_query_duration_seconds</code></td>
<td><?php esc_html_e( 'Histogram', 'wp-prometheus' ); ?></td>
<td><?php esc_html_e( 'Database query duration distribution (requires SAVEQUERIES)', 'wp-prometheus' ); ?></td>
</tr>
<tr> <tr>
<td><code>wordpress_cron_events_total</code></td> <td><code>wordpress_cron_events_total</code></td>
<td><?php esc_html_e( 'Gauge', 'wp-prometheus' ); ?></td> <td><?php esc_html_e( 'Gauge', 'wp-prometheus' ); ?></td>

View File

@@ -102,52 +102,12 @@ class MetricsEndpoint {
/** /**
* Authenticate the metrics request. * Authenticate the metrics request.
* *
* Uses the shared authentication helper to avoid code duplication
* with the isolated mode handler in wp-prometheus.php.
*
* @return bool * @return bool
*/ */
private function authenticate(): bool { private function authenticate(): bool {
$auth_token = get_option( 'wp_prometheus_auth_token', '' ); return wp_prometheus_authenticate_request();
// If no token is set, deny access.
if ( empty( $auth_token ) ) {
return false;
}
// Check for Bearer token in Authorization header.
$auth_header = $this->get_authorization_header();
if ( ! empty( $auth_header ) && preg_match( '/Bearer\s+(.*)$/i', $auth_header, $matches ) ) {
return hash_equals( $auth_token, $matches[1] );
}
// Check for token in query parameter (less secure but useful for testing).
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Auth token check.
if ( isset( $_GET['token'] ) && hash_equals( $auth_token, sanitize_text_field( wp_unslash( $_GET['token'] ) ) ) ) {
return true;
}
return false;
}
/**
* Get the Authorization header from the request.
*
* @return string
*/
private function get_authorization_header(): string {
if ( isset( $_SERVER['HTTP_AUTHORIZATION'] ) ) {
return sanitize_text_field( wp_unslash( $_SERVER['HTTP_AUTHORIZATION'] ) );
}
if ( isset( $_SERVER['REDIRECT_HTTP_AUTHORIZATION'] ) ) {
return sanitize_text_field( wp_unslash( $_SERVER['REDIRECT_HTTP_AUTHORIZATION'] ) );
}
if ( function_exists( 'apache_request_headers' ) ) {
$headers = apache_request_headers();
if ( isset( $headers['Authorization'] ) ) {
return sanitize_text_field( $headers['Authorization'] );
}
}
return '';
} }
} }

View File

@@ -65,6 +65,14 @@ final class Installer {
'wp_prometheus_enabled_metrics', 'wp_prometheus_enabled_metrics',
'wp_prometheus_runtime_metrics', 'wp_prometheus_runtime_metrics',
'wp_prometheus_custom_metrics', 'wp_prometheus_custom_metrics',
'wp_prometheus_isolated_mode',
'wp_prometheus_storage_adapter',
'wp_prometheus_redis_host',
'wp_prometheus_redis_port',
'wp_prometheus_redis_password',
'wp_prometheus_redis_database',
'wp_prometheus_redis_prefix',
'wp_prometheus_apcu_prefix',
); );
foreach ( $options as $option ) { foreach ( $options as $option ) {

View File

@@ -346,30 +346,69 @@ final class Manager {
/** /**
* Get the license key. * Get the license key.
* *
* Environment variable WP_PROMETHEUS_LICENSE_KEY takes precedence.
*
* @return string * @return string
*/ */
public static function get_license_key(): string { public static function get_license_key(): string {
$env = getenv( 'WP_PROMETHEUS_LICENSE_KEY' );
if ( false !== $env && '' !== $env ) {
return $env;
}
return get_option( self::OPTION_LICENSE_KEY, '' ); return get_option( self::OPTION_LICENSE_KEY, '' );
} }
/** /**
* Get the license server URL. * Get the license server URL.
* *
* Environment variable WP_PROMETHEUS_LICENSE_SERVER_URL takes precedence.
*
* @return string * @return string
*/ */
public static function get_server_url(): string { public static function get_server_url(): string {
$env = getenv( 'WP_PROMETHEUS_LICENSE_SERVER_URL' );
if ( false !== $env && '' !== $env ) {
return $env;
}
return get_option( self::OPTION_SERVER_URL, '' ); return get_option( self::OPTION_SERVER_URL, '' );
} }
/** /**
* Get the server secret. * Get the server secret.
* *
* Environment variable WP_PROMETHEUS_LICENSE_SERVER_SECRET takes precedence.
*
* @return string * @return string
*/ */
public static function get_server_secret(): string { public static function get_server_secret(): string {
$env = getenv( 'WP_PROMETHEUS_LICENSE_SERVER_SECRET' );
if ( false !== $env && '' !== $env ) {
return $env;
}
return get_option( self::OPTION_SERVER_SECRET, '' ); return get_option( self::OPTION_SERVER_SECRET, '' );
} }
/**
* Check if a license setting is overridden by an environment variable.
*
* @param string $setting One of 'server_url', 'license_key', 'server_secret'.
* @return bool
*/
public static function is_env_override( string $setting ): bool {
$map = array(
'server_url' => 'WP_PROMETHEUS_LICENSE_SERVER_URL',
'license_key' => 'WP_PROMETHEUS_LICENSE_KEY',
'server_secret' => 'WP_PROMETHEUS_LICENSE_SERVER_SECRET',
);
if ( ! isset( $map[ $setting ] ) ) {
return false;
}
$env = getenv( $map[ $setting ] );
return false !== $env && '' !== $env;
}
/** /**
* Get cached license status. * Get cached license status.
* *

View File

@@ -121,14 +121,62 @@ class Collector {
/** /**
* Fires after default metrics are collected. * Fires after default metrics are collected.
* *
* Skip in early metrics mode to avoid triggering third-party hooks * In isolated mode, skip custom hooks to avoid any potential issues.
* that may cause recursion issues (e.g., Twig-based plugins). * In safe mode (default), fire hooks with protection against recursion.
* *
* @param Collector $collector The metrics collector instance. * @param Collector $collector The metrics collector instance.
*/ */
if ( ! defined( 'WP_PROMETHEUS_EARLY_METRICS' ) || ! WP_PROMETHEUS_EARLY_METRICS ) { if ( defined( 'WP_PROMETHEUS_ISOLATED_MODE' ) && WP_PROMETHEUS_ISOLATED_MODE ) {
do_action( 'wp_prometheus_collect_metrics', $this ); // Isolated mode: skip all third-party hooks for maximum safety.
return;
} }
// Safe mode: fire custom hooks with protection.
$this->fire_custom_metrics_hook();
}
/**
* Fire custom metrics hook with protection against recursion.
*
* Removes potentially problematic filters, uses output buffering,
* and catches any errors from third-party plugins.
*
* @return void
*/
private function fire_custom_metrics_hook(): void {
// Remove content filters again (in case any plugin re-added them).
if ( function_exists( 'wp_prometheus_remove_content_filters' ) ) {
wp_prometheus_remove_content_filters();
} else {
// Fallback if function doesn't exist.
remove_all_filters( 'the_content' );
remove_all_filters( 'the_excerpt' );
remove_all_filters( 'get_the_excerpt' );
remove_all_filters( 'the_title' );
}
// Use output buffering to prevent any accidental output from plugins.
ob_start();
try {
/**
* Fires after default metrics are collected.
*
* Third-party plugins can use this hook to add custom metrics.
*
* @param Collector $collector The metrics collector instance.
*/
do_action( 'wp_prometheus_collect_metrics', $this );
} catch ( \Throwable $e ) {
// Log the error but don't let it break metrics output.
if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) {
// phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
error_log( 'WP Prometheus: Error in custom metrics hook: ' . $e->getMessage() );
}
}
// Discard any output from plugins.
ob_end_clean();
} }
/** /**
@@ -399,6 +447,16 @@ class Collector {
return class_exists( 'WooCommerce' ); return class_exists( 'WooCommerce' );
} }
/**
* Check if WooCommerce HPOS (High-Performance Order Storage) is enabled.
*
* @return bool
*/
private function is_hpos_enabled(): bool {
return class_exists( '\Automattic\WooCommerce\Utilities\OrderUtil' )
&& \Automattic\WooCommerce\Utilities\OrderUtil::custom_orders_table_usage_is_enabled();
}
/** /**
* Collect WooCommerce metrics. * Collect WooCommerce metrics.
* *
@@ -450,16 +508,17 @@ class Collector {
} }
} }
// Count by product type (for published products only). // Count by product type (for published products only) using count query.
foreach ( array_keys( $product_types ) as $type ) { foreach ( array_keys( $product_types ) as $type ) {
$args = array( $args = array(
'status' => 'publish', 'status' => 'publish',
'type' => $type, 'type' => $type,
'limit' => -1, 'limit' => 1,
'return' => 'ids', 'return' => 'ids',
'paginate' => true,
); );
$products = wc_get_products( $args ); $result = wc_get_products( $args );
$gauge->set( count( $products ), array( 'publish', $type ) ); $gauge->set( $result->total, array( 'publish', $type ) );
} }
} }
@@ -505,8 +564,7 @@ class Collector {
$currency = get_woocommerce_currency(); $currency = get_woocommerce_currency();
// Check if HPOS (High-Performance Order Storage) is enabled. // Check if HPOS (High-Performance Order Storage) is enabled.
$hpos_enabled = class_exists( '\Automattic\WooCommerce\Utilities\OrderUtil' ) $hpos_enabled = $this->is_hpos_enabled();
&& \Automattic\WooCommerce\Utilities\OrderUtil::custom_orders_table_usage_is_enabled();
if ( $hpos_enabled ) { if ( $hpos_enabled ) {
$orders_table = $wpdb->prefix . 'wc_orders'; $orders_table = $wpdb->prefix . 'wc_orders';

View File

@@ -47,6 +47,13 @@ class CustomMetricBuilder {
*/ */
const MAX_LABEL_VALUES = 50; const MAX_LABEL_VALUES = 50;
/**
* Maximum import JSON size in bytes (1 MB).
*
* @var int
*/
const MAX_IMPORT_SIZE = 1048576;
/** /**
* Get all custom metrics. * Get all custom metrics.
* *
@@ -332,7 +339,6 @@ class CustomMetricBuilder {
'version' => self::EXPORT_VERSION, 'version' => self::EXPORT_VERSION,
'plugin_version' => WP_PROMETHEUS_VERSION, 'plugin_version' => WP_PROMETHEUS_VERSION,
'exported_at' => gmdate( 'c' ), 'exported_at' => gmdate( 'c' ),
'site_url' => home_url(),
'metrics' => array_values( $metrics ), 'metrics' => array_values( $metrics ),
); );
@@ -348,6 +354,17 @@ class CustomMetricBuilder {
* @throws \InvalidArgumentException If JSON is invalid. * @throws \InvalidArgumentException If JSON is invalid.
*/ */
public function import( string $json, string $mode = 'skip' ): array { public function import( string $json, string $mode = 'skip' ): array {
// Prevent DoS via excessively large imports.
if ( strlen( $json ) > self::MAX_IMPORT_SIZE ) {
throw new \InvalidArgumentException(
sprintf(
/* translators: %s: Maximum size */
__( 'Import data exceeds maximum size of %s.', 'wp-prometheus' ),
size_format( self::MAX_IMPORT_SIZE )
)
);
}
$data = json_decode( $json, true ); $data = json_decode( $json, true );
if ( json_last_error() !== JSON_ERROR_NONE ) { if ( json_last_error() !== JSON_ERROR_NONE ) {
@@ -358,6 +375,12 @@ class CustomMetricBuilder {
throw new \InvalidArgumentException( __( 'No metrics found in import file.', 'wp-prometheus' ) ); throw new \InvalidArgumentException( __( 'No metrics found in import file.', 'wp-prometheus' ) );
} }
// Validate import mode.
$valid_modes = array( 'skip', 'overwrite', 'rename' );
if ( ! in_array( $mode, $valid_modes, true ) ) {
$mode = 'skip';
}
$result = array( $result = array(
'imported' => 0, 'imported' => 0,
'skipped' => 0, 'skipped' => 0,

View File

@@ -57,7 +57,9 @@ final class Plugin {
private function __construct() { private function __construct() {
$this->init_components(); $this->init_components();
$this->init_hooks(); $this->init_hooks();
$this->load_textdomain();
// Defer textdomain loading to 'init' action (required since WordPress 6.7).
add_action( 'init', array( $this, 'load_textdomain' ) );
} }
/** /**
@@ -144,9 +146,11 @@ final class Plugin {
/** /**
* Load plugin textdomain. * Load plugin textdomain.
* *
* Hooked to 'init' action to comply with WordPress 6.7+ requirements.
*
* @return void * @return void
*/ */
private function load_textdomain(): void { public function load_textdomain(): void {
load_plugin_textdomain( load_plugin_textdomain(
'wp-prometheus', 'wp-prometheus',
false, false,

View File

@@ -0,0 +1,51 @@
<?php
declare(strict_types=1);
namespace Magdev\WpPrometheus\Tests\Helpers;
/**
* Controllable state for global WordPress function stubs.
*
* Used by the bootstrap's global get_option() stub so that tests
* for global-scope functions (e.g., wp_prometheus_authenticate_request)
* can control return values without php-mock (which requires namespaces).
*/
class GlobalFunctionState
{
/** @var array<string, mixed> Simulated WordPress options. */
public static array $options = [];
/** @var array<string, int> Track function call counts. */
public static array $callCounts = [];
/** @var array<string, list<mixed>> Track arguments passed to functions. */
public static array $callArgs = [];
/**
* Reset all state. Call in setUp()/tearDown().
*/
public static function reset(): void
{
self::$options = [];
self::$callCounts = [];
self::$callArgs = [];
}
/**
* Record a function call for later assertions.
*/
public static function recordCall(string $function, mixed ...$args): void
{
self::$callCounts[$function] = (self::$callCounts[$function] ?? 0) + 1;
self::$callArgs[$function][] = $args;
}
/**
* Get call count for a function.
*/
public static function getCallCount(string $function): int
{
return self::$callCounts[$function] ?? 0;
}
}

View File

@@ -0,0 +1,376 @@
<?php
declare(strict_types=1);
namespace Magdev\WpPrometheus\Tests\Unit\Admin;
use Magdev\WpPrometheus\Admin\DashboardProvider;
use Magdev\WpPrometheus\Tests\Helpers\GlobalFunctionState;
use Magdev\WpPrometheus\Tests\Unit\TestCase;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\Test;
#[CoversClass(DashboardProvider::class)]
class DashboardProviderTest extends TestCase
{
private DashboardProvider $provider;
protected function setUp(): void
{
parent::setUp();
$this->provider = new DashboardProvider();
}
// ── register_dashboard() - Validation ────────────────────────────
#[Test]
public function register_with_inline_json_succeeds(): void
{
$result = $this->provider->register_dashboard('my-dashboard', [
'title' => 'My Dashboard',
'json' => '{"panels":[]}',
]);
$this->assertTrue($result);
}
#[Test]
public function register_rejects_empty_slug(): void
{
$result = $this->provider->register_dashboard('', [
'title' => 'Test',
'json' => '{}',
]);
$this->assertFalse($result);
}
#[Test]
public function register_rejects_invalid_slug_characters(): void
{
// sanitize_key removes all non [a-z0-9_-] characters
// A slug like '!@#' becomes '' after sanitize_key
$result = $this->provider->register_dashboard('!@#', [
'title' => 'Test',
'json' => '{}',
]);
$this->assertFalse($result);
}
#[Test]
public function register_rejects_duplicate_builtin_slug(): void
{
$result = $this->provider->register_dashboard('wordpress-overview', [
'title' => 'Override Built-in',
'json' => '{}',
]);
$this->assertFalse($result);
}
#[Test]
public function register_rejects_duplicate_registered_slug(): void
{
$this->provider->register_dashboard('my-dashboard', [
'title' => 'First',
'json' => '{}',
]);
$result = $this->provider->register_dashboard('my-dashboard', [
'title' => 'Second',
'json' => '{}',
]);
$this->assertFalse($result);
}
#[Test]
public function register_requires_title(): void
{
$result = $this->provider->register_dashboard('no-title', [
'json' => '{}',
]);
$this->assertFalse($result);
}
#[Test]
public function register_requires_file_or_json(): void
{
$result = $this->provider->register_dashboard('no-content', [
'title' => 'No Content',
]);
$this->assertFalse($result);
}
#[Test]
public function register_rejects_both_file_and_json(): void
{
$fileExists = $this->getFunctionMock('Magdev\\WpPrometheus\\Admin', 'file_exists');
$fileExists->expects($this->any())->willReturn(true);
$isReadable = $this->getFunctionMock('Magdev\\WpPrometheus\\Admin', 'is_readable');
$isReadable->expects($this->any())->willReturn(true);
$realpath = $this->getFunctionMock('Magdev\\WpPrometheus\\Admin', 'realpath');
$realpath->expects($this->any())->willReturnArgument(0);
$result = $this->provider->register_dashboard('both', [
'title' => 'Both',
'file' => '/tmp/wordpress/wp-content/plugins/test/dashboard.json',
'json' => '{}',
]);
$this->assertFalse($result);
}
#[Test]
public function register_file_requires_absolute_path(): void
{
$result = $this->provider->register_dashboard('relative', [
'title' => 'Relative Path',
'file' => 'relative/path/dashboard.json',
]);
$this->assertFalse($result);
}
#[Test]
public function register_file_must_exist(): void
{
$fileExists = $this->getFunctionMock('Magdev\\WpPrometheus\\Admin', 'file_exists');
$fileExists->expects($this->any())->willReturn(false);
$result = $this->provider->register_dashboard('missing-file', [
'title' => 'Missing File',
'file' => '/tmp/wordpress/wp-content/plugins/test/nonexistent.json',
]);
$this->assertFalse($result);
}
#[Test]
public function register_file_rejects_path_traversal(): void
{
$fileExists = $this->getFunctionMock('Magdev\\WpPrometheus\\Admin', 'file_exists');
$fileExists->expects($this->any())->willReturn(true);
$isReadable = $this->getFunctionMock('Magdev\\WpPrometheus\\Admin', 'is_readable');
$isReadable->expects($this->any())->willReturn(true);
$realpath = $this->getFunctionMock('Magdev\\WpPrometheus\\Admin', 'realpath');
$realpath->expects($this->any())->willReturnArgument(0);
$result = $this->provider->register_dashboard('evil', [
'title' => 'Evil Dashboard',
'file' => '/etc/passwd',
]);
$this->assertFalse($result);
}
#[Test]
public function register_file_accepts_path_under_wp_content(): void
{
$fileExists = $this->getFunctionMock('Magdev\\WpPrometheus\\Admin', 'file_exists');
$fileExists->expects($this->any())->willReturn(true);
$isReadable = $this->getFunctionMock('Magdev\\WpPrometheus\\Admin', 'is_readable');
$isReadable->expects($this->any())->willReturn(true);
$realpath = $this->getFunctionMock('Magdev\\WpPrometheus\\Admin', 'realpath');
$realpath->expects($this->any())->willReturnArgument(0);
$result = $this->provider->register_dashboard('valid-file', [
'title' => 'Valid File Dashboard',
'file' => '/tmp/wordpress/wp-content/plugins/my-plugin/dashboard.json',
]);
$this->assertTrue($result);
}
#[Test]
public function register_rejects_invalid_inline_json(): void
{
$result = $this->provider->register_dashboard('bad-json', [
'title' => 'Bad JSON',
'json' => '{invalid json',
]);
$this->assertFalse($result);
}
#[Test]
public function register_sets_source_to_third_party(): void
{
$this->provider->register_dashboard('ext-dashboard', [
'title' => 'Extension',
'json' => '{}',
'plugin' => 'My Plugin',
]);
$this->assertTrue($this->provider->is_third_party('ext-dashboard'));
}
// ── get_available() ──────────────────────────────────────────────
#[Test]
public function get_available_includes_builtin_dashboards(): void
{
$fileExists = $this->getFunctionMock('Magdev\\WpPrometheus\\Admin', 'file_exists');
$fileExists->expects($this->any())->willReturn(true);
$available = $this->provider->get_available();
$this->assertArrayHasKey('wordpress-overview', $available);
$this->assertArrayHasKey('wordpress-runtime', $available);
$this->assertArrayHasKey('wordpress-woocommerce', $available);
}
#[Test]
public function get_available_includes_registered_dashboards(): void
{
$fileExists = $this->getFunctionMock('Magdev\\WpPrometheus\\Admin', 'file_exists');
$fileExists->expects($this->any())->willReturn(true);
$this->provider->register_dashboard('custom-dash', [
'title' => 'Custom',
'json' => '{"panels":[]}',
]);
$available = $this->provider->get_available();
$this->assertArrayHasKey('custom-dash', $available);
}
#[Test]
public function get_available_excludes_builtin_with_missing_file(): void
{
$fileExists = $this->getFunctionMock('Magdev\\WpPrometheus\\Admin', 'file_exists');
$fileExists->expects($this->any())->willReturn(false);
$available = $this->provider->get_available();
// Built-in dashboards should be excluded (files don't exist).
$this->assertArrayNotHasKey('wordpress-overview', $available);
}
// ── is_third_party() ─────────────────────────────────────────────
#[Test]
public function builtin_dashboard_is_not_third_party(): void
{
$this->assertFalse($this->provider->is_third_party('wordpress-overview'));
}
#[Test]
public function registered_dashboard_is_third_party(): void
{
$this->provider->register_dashboard('ext-dash', [
'title' => 'Extension Dashboard',
'json' => '{}',
]);
$this->assertTrue($this->provider->is_third_party('ext-dash'));
}
#[Test]
public function unknown_slug_is_not_third_party(): void
{
$this->assertFalse($this->provider->is_third_party('nonexistent'));
}
// ── get_plugin_name() ────────────────────────────────────────────
#[Test]
public function get_plugin_name_returns_name_for_registered(): void
{
$this->provider->register_dashboard('ext-dash', [
'title' => 'Extension',
'json' => '{}',
'plugin' => 'My Awesome Plugin',
]);
$this->assertSame('My Awesome Plugin', $this->provider->get_plugin_name('ext-dash'));
}
#[Test]
public function get_plugin_name_returns_null_for_builtin(): void
{
$this->assertNull($this->provider->get_plugin_name('wordpress-overview'));
}
#[Test]
public function get_plugin_name_returns_null_when_empty(): void
{
$this->provider->register_dashboard('no-plugin', [
'title' => 'No Plugin',
'json' => '{}',
]);
$this->assertNull($this->provider->get_plugin_name('no-plugin'));
}
// ── get_filename() ───────────────────────────────────────────────
#[Test]
public function get_filename_returns_file_for_builtin(): void
{
$filename = $this->provider->get_filename('wordpress-overview');
$this->assertSame('wordpress-overview.json', $filename);
}
#[Test]
public function get_filename_returns_slug_json_for_inline(): void
{
$this->provider->register_dashboard('my-dash', [
'title' => 'My Dashboard',
'json' => '{}',
]);
$this->assertSame('my-dash.json', $this->provider->get_filename('my-dash'));
}
#[Test]
public function get_filename_returns_null_for_unknown(): void
{
$this->assertNull($this->provider->get_filename('nonexistent'));
}
// ── get_metadata() ───────────────────────────────────────────────
#[Test]
public function get_metadata_returns_builtin_data(): void
{
$metadata = $this->provider->get_metadata('wordpress-overview');
$this->assertIsArray($metadata);
$this->assertSame('WordPress Overview', $metadata['title']);
$this->assertSame('builtin', $metadata['source']);
}
#[Test]
public function get_metadata_returns_registered_data(): void
{
$this->provider->register_dashboard('ext-dash', [
'title' => 'Extension',
'description' => 'A test dashboard',
'json' => '{}',
'plugin' => 'TestPlugin',
]);
$metadata = $this->provider->get_metadata('ext-dash');
$this->assertIsArray($metadata);
$this->assertSame('Extension', $metadata['title']);
$this->assertSame('third-party', $metadata['source']);
$this->assertSame('TestPlugin', $metadata['plugin']);
}
#[Test]
public function get_metadata_returns_null_for_unknown(): void
{
$this->assertNull($this->provider->get_metadata('nonexistent'));
}
}

View File

@@ -0,0 +1,148 @@
<?php
declare(strict_types=1);
namespace Magdev\WpPrometheus\Tests\Unit;
use Magdev\WpPrometheus\Tests\Helpers\GlobalFunctionState;
use PHPUnit\Framework\Attributes\CoversFunction;
use PHPUnit\Framework\Attributes\Test;
#[CoversFunction('wp_prometheus_authenticate_request')]
#[CoversFunction('wp_prometheus_get_authorization_header')]
class AuthenticationTest extends TestCase
{
private array $originalServer = [];
private array $originalGet = [];
protected function setUp(): void
{
parent::setUp();
$this->originalServer = $_SERVER;
$this->originalGet = $_GET;
}
protected function tearDown(): void
{
$_SERVER = $this->originalServer;
$_GET = $this->originalGet;
parent::tearDown();
}
// ── wp_prometheus_authenticate_request() ─────────────────────────
#[Test]
public function returns_false_when_no_token_configured(): void
{
// No auth token in options → deny all.
$this->assertFalse(wp_prometheus_authenticate_request());
}
#[Test]
public function returns_false_when_token_is_empty_string(): void
{
GlobalFunctionState::$options['wp_prometheus_auth_token'] = '';
$this->assertFalse(wp_prometheus_authenticate_request());
}
#[Test]
public function bearer_token_authenticates_successfully(): void
{
GlobalFunctionState::$options['wp_prometheus_auth_token'] = 'secret-token-123';
$_SERVER['HTTP_AUTHORIZATION'] = 'Bearer secret-token-123';
$this->assertTrue(wp_prometheus_authenticate_request());
}
#[Test]
public function bearer_token_fails_with_wrong_token(): void
{
GlobalFunctionState::$options['wp_prometheus_auth_token'] = 'secret-token-123';
$_SERVER['HTTP_AUTHORIZATION'] = 'Bearer wrong-token';
$this->assertFalse(wp_prometheus_authenticate_request());
}
#[Test]
public function bearer_prefix_is_case_insensitive(): void
{
GlobalFunctionState::$options['wp_prometheus_auth_token'] = 'secret-token-123';
$_SERVER['HTTP_AUTHORIZATION'] = 'BEARER secret-token-123';
$this->assertTrue(wp_prometheus_authenticate_request());
}
#[Test]
public function query_parameter_authenticates_successfully(): void
{
GlobalFunctionState::$options['wp_prometheus_auth_token'] = 'secret-token-123';
$_GET['token'] = 'secret-token-123';
$this->assertTrue(wp_prometheus_authenticate_request());
}
#[Test]
public function query_parameter_fails_with_wrong_token(): void
{
GlobalFunctionState::$options['wp_prometheus_auth_token'] = 'secret-token-123';
$_GET['token'] = 'wrong-token';
$this->assertFalse(wp_prometheus_authenticate_request());
}
#[Test]
public function returns_false_when_no_auth_provided(): void
{
GlobalFunctionState::$options['wp_prometheus_auth_token'] = 'secret-token-123';
unset($_SERVER['HTTP_AUTHORIZATION'], $_SERVER['REDIRECT_HTTP_AUTHORIZATION']);
unset($_GET['token']);
$this->assertFalse(wp_prometheus_authenticate_request());
}
#[Test]
public function bearer_takes_precedence_over_query_parameter(): void
{
GlobalFunctionState::$options['wp_prometheus_auth_token'] = 'correct-token';
$_SERVER['HTTP_AUTHORIZATION'] = 'Bearer correct-token';
$_GET['token'] = 'wrong-token';
$this->assertTrue(wp_prometheus_authenticate_request());
}
// ── wp_prometheus_get_authorization_header() ─────────────────────
#[Test]
public function get_authorization_header_from_http_authorization(): void
{
$_SERVER['HTTP_AUTHORIZATION'] = 'Bearer my-token';
$this->assertSame('Bearer my-token', wp_prometheus_get_authorization_header());
}
#[Test]
public function get_authorization_header_from_redirect(): void
{
unset($_SERVER['HTTP_AUTHORIZATION']);
$_SERVER['REDIRECT_HTTP_AUTHORIZATION'] = 'Bearer redirect-token';
$this->assertSame('Bearer redirect-token', wp_prometheus_get_authorization_header());
}
#[Test]
public function get_authorization_header_returns_empty_when_absent(): void
{
unset($_SERVER['HTTP_AUTHORIZATION'], $_SERVER['REDIRECT_HTTP_AUTHORIZATION']);
$this->assertSame('', wp_prometheus_get_authorization_header());
}
#[Test]
public function http_authorization_takes_precedence_over_redirect(): void
{
$_SERVER['HTTP_AUTHORIZATION'] = 'Bearer primary';
$_SERVER['REDIRECT_HTTP_AUTHORIZATION'] = 'Bearer redirect';
$this->assertSame('Bearer primary', wp_prometheus_get_authorization_header());
}
}

View File

@@ -0,0 +1,102 @@
<?php
declare(strict_types=1);
namespace Magdev\WpPrometheus\Tests\Unit\Endpoint;
use Magdev\WpPrometheus\Endpoint\MetricsEndpoint;
use Magdev\WpPrometheus\Metrics\Collector;
use Magdev\WpPrometheus\Metrics\StorageFactory;
use Magdev\WpPrometheus\Tests\Helpers\GlobalFunctionState;
use Magdev\WpPrometheus\Tests\Unit\TestCase;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\Test;
#[CoversClass(MetricsEndpoint::class)]
class MetricsEndpointTest extends TestCase
{
private Collector $collector;
private MetricsEndpoint $endpoint;
protected function setUp(): void
{
parent::setUp();
$this->resetStorageFactory();
$this->collector = new Collector();
$this->endpoint = new MetricsEndpoint($this->collector);
}
protected function tearDown(): void
{
$this->resetStorageFactory();
parent::tearDown();
}
// ── Constructor ───────────────────────────────────────────────
#[Test]
public function constructor_accepts_collector(): void
{
$this->assertInstanceOf(MetricsEndpoint::class, $this->endpoint);
}
// ── register_endpoint() ───────────────────────────────────────
#[Test]
public function register_endpoint_adds_rewrite_rule(): void
{
$this->endpoint->register_endpoint();
$this->assertSame(1, GlobalFunctionState::getCallCount('add_rewrite_rule'));
$args = GlobalFunctionState::$callArgs['add_rewrite_rule'][0];
$this->assertSame('^metrics/?$', $args[0]);
$this->assertSame('index.php?wp_prometheus_metrics=1', $args[1]);
$this->assertSame('top', $args[2]);
}
#[Test]
public function register_endpoint_adds_rewrite_tag(): void
{
$this->endpoint->register_endpoint();
$this->assertSame(1, GlobalFunctionState::getCallCount('add_rewrite_tag'));
$args = GlobalFunctionState::$callArgs['add_rewrite_tag'][0];
$this->assertSame('%wp_prometheus_metrics%', $args[0]);
$this->assertSame('([^&]+)', $args[1]);
}
// ── handle_request() ──────────────────────────────────────────
#[Test]
public function handle_request_returns_early_when_no_query_var(): void
{
$wp = new \WP();
$wp->query_vars = [];
// Should return without calling exit or outputting anything.
$this->endpoint->handle_request($wp);
// If we reach this assertion, handle_request returned early (no exit).
$this->assertTrue(true);
}
#[Test]
public function handle_request_returns_early_when_query_var_empty(): void
{
$wp = new \WP();
$wp->query_vars = ['wp_prometheus_metrics' => ''];
$this->endpoint->handle_request($wp);
$this->assertTrue(true);
}
// ── Helpers ──────────────────────────────────────────────────
private function resetStorageFactory(): void
{
$reflection = new \ReflectionClass(StorageFactory::class);
$property = $reflection->getProperty('instance');
$property->setValue(null, null);
}
}

View File

@@ -0,0 +1,189 @@
<?php
declare(strict_types=1);
namespace Magdev\WpPrometheus\Tests\Unit;
use Magdev\WpPrometheus\Installer;
use Magdev\WpPrometheus\Tests\Helpers\GlobalFunctionState;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\Test;
#[CoversClass(Installer::class)]
class InstallerTest extends TestCase
{
// ── activate() ───────────────────────────────────────────────────
#[Test]
public function activate_stores_activation_time(): void
{
Installer::activate();
$this->assertArrayHasKey(
'wp_prometheus_activated',
GlobalFunctionState::$options
);
$this->assertIsInt(GlobalFunctionState::$options['wp_prometheus_activated']);
}
#[Test]
public function activate_flushes_rewrite_rules(): void
{
Installer::activate();
$this->assertGreaterThanOrEqual(
1,
GlobalFunctionState::getCallCount('flush_rewrite_rules')
);
}
#[Test]
public function activate_generates_auth_token_when_not_set(): void
{
Installer::activate();
$this->assertArrayHasKey(
'wp_prometheus_auth_token',
GlobalFunctionState::$options
);
$this->assertNotEmpty(GlobalFunctionState::$options['wp_prometheus_auth_token']);
}
#[Test]
public function activate_preserves_existing_auth_token(): void
{
GlobalFunctionState::$options['wp_prometheus_auth_token'] = 'existing-token';
Installer::activate();
$this->assertSame(
'existing-token',
GlobalFunctionState::$options['wp_prometheus_auth_token']
);
}
#[Test]
public function activate_enables_default_metrics(): void
{
Installer::activate();
$this->assertSame(
1,
GlobalFunctionState::$options['wp_prometheus_enable_default_metrics']
);
}
#[Test]
public function activate_sets_default_enabled_metrics_list(): void
{
Installer::activate();
$enabled = GlobalFunctionState::$options['wp_prometheus_enabled_metrics'];
$this->assertIsArray($enabled);
$this->assertContains('wordpress_info', $enabled);
$this->assertContains('wordpress_users_total', $enabled);
$this->assertContains('wordpress_posts_total', $enabled);
$this->assertContains('wordpress_comments_total', $enabled);
$this->assertContains('wordpress_plugins_total', $enabled);
}
#[Test]
public function activate_preserves_existing_enabled_metrics(): void
{
$custom = ['wordpress_info', 'wordpress_users_total'];
GlobalFunctionState::$options['wp_prometheus_enabled_metrics'] = $custom;
Installer::activate();
$this->assertSame(
$custom,
GlobalFunctionState::$options['wp_prometheus_enabled_metrics']
);
}
// ── deactivate() ─────────────────────────────────────────────────
#[Test]
public function deactivate_flushes_rewrite_rules(): void
{
Installer::deactivate();
$this->assertGreaterThanOrEqual(
1,
GlobalFunctionState::getCallCount('flush_rewrite_rules')
);
}
// ── uninstall() ──────────────────────────────────────────────────
#[Test]
public function uninstall_removes_all_plugin_options(): void
{
// Set up options that should be cleaned.
$expected_options = [
'wp_prometheus_activated',
'wp_prometheus_license_key',
'wp_prometheus_license_server_url',
'wp_prometheus_license_server_secret',
'wp_prometheus_license_status',
'wp_prometheus_license_data',
'wp_prometheus_license_last_check',
'wp_prometheus_auth_token',
'wp_prometheus_enable_default_metrics',
'wp_prometheus_enabled_metrics',
'wp_prometheus_runtime_metrics',
'wp_prometheus_custom_metrics',
'wp_prometheus_isolated_mode',
'wp_prometheus_storage_adapter',
'wp_prometheus_redis_host',
'wp_prometheus_redis_port',
'wp_prometheus_redis_password',
'wp_prometheus_redis_database',
'wp_prometheus_redis_prefix',
'wp_prometheus_apcu_prefix',
];
foreach ($expected_options as $option) {
GlobalFunctionState::$options[$option] = 'test_value';
}
Installer::uninstall();
// Verify all options were deleted.
foreach ($expected_options as $option) {
$this->assertArrayNotHasKey(
$option,
GlobalFunctionState::$options,
"Option '$option' was not deleted during uninstall"
);
}
}
#[Test]
public function uninstall_removes_license_transient(): void
{
Installer::uninstall();
$this->assertGreaterThanOrEqual(
1,
GlobalFunctionState::getCallCount('delete_transient')
);
// Verify the specific transient was targeted.
$args = GlobalFunctionState::$callArgs['delete_transient'] ?? [];
$transientNames = array_column($args, 0);
$this->assertContains('wp_prometheus_license_check', $transientNames);
}
#[Test]
public function uninstall_delete_option_call_count_matches(): void
{
Installer::uninstall();
// Should call delete_option for each option in the list (20 options).
$this->assertSame(
20,
GlobalFunctionState::getCallCount('delete_option')
);
}
}

View File

@@ -0,0 +1,155 @@
<?php
declare(strict_types=1);
namespace Magdev\WpPrometheus\Tests\Unit\Metrics;
use Magdev\WpPrometheus\Metrics\Collector;
use Magdev\WpPrometheus\Metrics\RuntimeCollector;
use Magdev\WpPrometheus\Metrics\StorageFactory;
use Magdev\WpPrometheus\Tests\Unit\TestCase;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\Test;
use Prometheus\CollectorRegistry;
use Prometheus\Counter;
use Prometheus\Gauge;
use Prometheus\Histogram;
#[CoversClass(Collector::class)]
class CollectorTest extends TestCase
{
private Collector $collector;
protected function setUp(): void
{
parent::setUp();
$this->resetStorageFactory();
$this->collector = new Collector();
}
protected function tearDown(): void
{
$this->resetRuntimeCollectorSingleton();
$this->resetStorageFactory();
parent::tearDown();
}
// ── Constructor & Basic Properties ─────────────────────────────
#[Test]
public function constructor_creates_registry(): void
{
$this->assertInstanceOf(CollectorRegistry::class, $this->collector->get_registry());
}
#[Test]
public function get_namespace_returns_wordpress(): void
{
$this->assertSame('wordpress', $this->collector->get_namespace());
}
// ── register_gauge() ──────────────────────────────────────────
#[Test]
public function register_gauge_returns_gauge_instance(): void
{
$gauge = $this->collector->register_gauge('test_metric', 'A test gauge');
$this->assertInstanceOf(Gauge::class, $gauge);
}
#[Test]
public function register_gauge_with_labels(): void
{
$gauge = $this->collector->register_gauge(
'labeled_metric',
'A labeled gauge',
['label1', 'label2']
);
$this->assertInstanceOf(Gauge::class, $gauge);
}
// ── register_counter() ────────────────────────────────────────
#[Test]
public function register_counter_returns_counter_instance(): void
{
$counter = $this->collector->register_counter('test_counter', 'A test counter');
$this->assertInstanceOf(Counter::class, $counter);
}
#[Test]
public function register_counter_with_labels(): void
{
$counter = $this->collector->register_counter(
'labeled_counter',
'A labeled counter',
['method', 'status']
);
$this->assertInstanceOf(Counter::class, $counter);
}
// ── register_histogram() ──────────────────────────────────────
#[Test]
public function register_histogram_returns_histogram_instance(): void
{
$histogram = $this->collector->register_histogram('test_histogram', 'A test histogram');
$this->assertInstanceOf(Histogram::class, $histogram);
}
#[Test]
public function register_histogram_with_custom_buckets(): void
{
$buckets = [0.1, 0.5, 1.0, 5.0];
$histogram = $this->collector->register_histogram(
'custom_buckets_hist',
'A histogram with custom buckets',
['label1'],
$buckets
);
$this->assertInstanceOf(Histogram::class, $histogram);
}
// ── render() ──────────────────────────────────────────────────
#[Test]
public function render_returns_string(): void
{
$getOption = $this->getFunctionMock('Magdev\\WpPrometheus\\Metrics', 'get_option');
$getOption->expects($this->any())->willReturn([]);
$output = $this->collector->render();
$this->assertIsString($output);
}
#[Test]
public function render_includes_registered_gauge_value(): void
{
$getOption = $this->getFunctionMock('Magdev\\WpPrometheus\\Metrics', 'get_option');
$getOption->expects($this->any())->willReturn([]);
$gauge = $this->collector->register_gauge('test_render_metric', 'Test metric for render');
$gauge->set(42, []);
$output = $this->collector->render();
$this->assertStringContainsString('wordpress_test_render_metric', $output);
$this->assertStringContainsString('42', $output);
}
// ── Helpers ──────────────────────────────────────────────────
private function resetRuntimeCollectorSingleton(): void
{
$reflection = new \ReflectionClass(RuntimeCollector::class);
$property = $reflection->getProperty('instance');
$property->setValue(null, null);
}
private function resetStorageFactory(): void
{
$reflection = new \ReflectionClass(StorageFactory::class);
$property = $reflection->getProperty('instance');
$property->setValue(null, null);
}
}

View File

@@ -0,0 +1,655 @@
<?php
declare(strict_types=1);
namespace Magdev\WpPrometheus\Tests\Unit\Metrics;
use Magdev\WpPrometheus\Metrics\CustomMetricBuilder;
use Magdev\WpPrometheus\Tests\Helpers\GlobalFunctionState;
use Magdev\WpPrometheus\Tests\Unit\TestCase;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\Attributes\Test;
#[CoversClass(CustomMetricBuilder::class)]
class CustomMetricBuilderTest extends TestCase
{
private CustomMetricBuilder $builder;
protected function setUp(): void
{
parent::setUp();
$this->builder = new CustomMetricBuilder();
}
// ── validate_name() ──────────────────────────────────────────────
#[Test]
#[DataProvider('validMetricNamesProvider')]
public function validate_name_accepts_valid_names(string $name): void
{
$this->assertTrue($this->builder->validate_name($name));
}
public static function validMetricNamesProvider(): array
{
return [
'simple' => ['my_metric'],
'with_colon' => ['my:metric'],
'starts_with_underscore' => ['_private_metric'],
'starts_with_colon' => [':special_metric'],
'uppercase' => ['MY_METRIC'],
'mixed_case' => ['myMetric123'],
'single_letter' => ['m'],
];
}
#[Test]
#[DataProvider('invalidMetricNamesProvider')]
public function validate_name_rejects_invalid_names(string $name): void
{
$this->assertFalse($this->builder->validate_name($name));
}
public static function invalidMetricNamesProvider(): array
{
return [
'starts_with_digit' => ['0metric'],
'contains_dash' => ['my-metric'],
'contains_space' => ['my metric'],
'contains_dot' => ['my.metric'],
'empty_string' => [''],
'special_chars' => ['metric@name'],
];
}
// ── validate_label_name() ────────────────────────────────────────
#[Test]
#[DataProvider('validLabelNamesProvider')]
public function validate_label_name_accepts_valid_names(string $name): void
{
$this->assertTrue($this->builder->validate_label_name($name));
}
public static function validLabelNamesProvider(): array
{
return [
'simple' => ['status'],
'with_underscore' => ['http_method'],
'starts_with_underscore' => ['_internal'],
'uppercase' => ['METHOD'],
'alphanumeric' => ['label1'],
];
}
#[Test]
#[DataProvider('invalidLabelNamesProvider')]
public function validate_label_name_rejects_invalid_names(string $name): void
{
$this->assertFalse($this->builder->validate_label_name($name));
}
public static function invalidLabelNamesProvider(): array
{
return [
'double_underscore_prefix' => ['__reserved'],
'starts_with_digit' => ['1label'],
'contains_colon' => ['label:name'],
'contains_dash' => ['label-name'],
'empty_string' => [''],
'contains_space' => ['label name'],
];
}
// ── validate() ───────────────────────────────────────────────────
#[Test]
public function validate_returns_empty_for_valid_metric(): void
{
$errors = $this->builder->validate($this->validMetric());
$this->assertEmpty($errors);
}
#[Test]
public function validate_requires_name(): void
{
$metric = $this->validMetric();
$metric['name'] = '';
$errors = $this->builder->validate($metric);
$this->assertNotEmpty($errors);
$this->assertStringContainsString('name is required', $errors[0]);
}
#[Test]
public function validate_rejects_invalid_metric_name(): void
{
$metric = $this->validMetric();
$metric['name'] = '0invalid';
$errors = $this->builder->validate($metric);
$this->assertNotEmpty($errors);
$this->assertStringContainsString('must start with', $errors[0]);
}
#[Test]
#[DataProvider('reservedPrefixProvider')]
public function validate_rejects_reserved_prefix(string $prefix): void
{
$metric = $this->validMetric();
$metric['name'] = $prefix . 'test';
$errors = $this->builder->validate($metric);
$this->assertNotEmpty($errors);
$this->assertStringContainsString('reserved prefix', implode(' ', $errors));
}
public static function reservedPrefixProvider(): array
{
return [
'wordpress_' => ['wordpress_'],
'go_' => ['go_'],
'process_' => ['process_'],
'promhttp_' => ['promhttp_'],
];
}
#[Test]
public function validate_detects_duplicate_name(): void
{
GlobalFunctionState::$options[CustomMetricBuilder::OPTION_NAME] = [
'existing-id' => [
'id' => 'existing-id',
'name' => 'custom_existing',
'help' => 'test',
],
];
$metric = $this->validMetric();
$metric['name'] = 'custom_existing';
$errors = $this->builder->validate($metric);
$this->assertNotEmpty($errors);
$this->assertStringContainsString('already exists', implode(' ', $errors));
}
#[Test]
public function validate_allows_same_name_when_editing(): void
{
GlobalFunctionState::$options[CustomMetricBuilder::OPTION_NAME] = [
'my-id' => [
'id' => 'my-id',
'name' => 'custom_existing',
'help' => 'test',
],
];
$metric = $this->validMetric();
$metric['id'] = 'my-id';
$metric['name'] = 'custom_existing';
$errors = $this->builder->validate($metric);
$this->assertEmpty($errors);
}
#[Test]
public function validate_requires_help_text(): void
{
$metric = $this->validMetric();
$metric['help'] = '';
$errors = $this->builder->validate($metric);
$this->assertNotEmpty($errors);
$this->assertStringContainsString('Help text is required', $errors[0]);
}
#[Test]
public function validate_requires_valid_type(): void
{
$metric = $this->validMetric();
$metric['type'] = 'counter';
$errors = $this->builder->validate($metric);
$this->assertNotEmpty($errors);
$this->assertStringContainsString('Invalid metric type', implode(' ', $errors));
}
#[Test]
public function validate_rejects_too_many_labels(): void
{
$metric = $this->validMetric();
$metric['labels'] = ['a', 'b', 'c', 'd', 'e', 'f'];
$errors = $this->builder->validate($metric);
$this->assertNotEmpty($errors);
$this->assertStringContainsString('Maximum', implode(' ', $errors));
}
#[Test]
public function validate_rejects_invalid_label_names_in_array(): void
{
$metric = $this->validMetric();
$metric['labels'] = ['valid', '__reserved'];
$errors = $this->builder->validate($metric);
$this->assertNotEmpty($errors);
$this->assertStringContainsString('Invalid label name', implode(' ', $errors));
}
#[Test]
public function validate_rejects_non_array_labels(): void
{
$metric = $this->validMetric();
$metric['labels'] = 'not_an_array';
$errors = $this->builder->validate($metric);
$this->assertNotEmpty($errors);
$this->assertStringContainsString('Labels must be an array', implode(' ', $errors));
}
#[Test]
public function validate_requires_valid_value_type(): void
{
$metric = $this->validMetric();
$metric['value_type'] = 'invalid';
$errors = $this->builder->validate($metric);
$this->assertNotEmpty($errors);
$this->assertStringContainsString('Invalid value type', implode(' ', $errors));
}
#[Test]
public function validate_requires_option_name_for_option_type(): void
{
$metric = $this->validMetric();
$metric['value_type'] = 'option';
$metric['value_config'] = [];
$errors = $this->builder->validate($metric);
$this->assertNotEmpty($errors);
$this->assertStringContainsString('Option name is required', implode(' ', $errors));
}
#[Test]
public function validate_accepts_option_type_with_option_name(): void
{
$metric = $this->validMetric();
$metric['value_type'] = 'option';
$metric['value_config'] = ['option_name' => 'my_wp_option'];
$errors = $this->builder->validate($metric);
$this->assertEmpty($errors);
}
#[Test]
public function validate_rejects_too_many_label_values(): void
{
$metric = $this->validMetric();
$metric['labels'] = ['status'];
$metric['label_values'] = array_fill(0, 51, ['active', 1.0]);
$errors = $this->builder->validate($metric);
$this->assertNotEmpty($errors);
$this->assertStringContainsString('Maximum', implode(' ', $errors));
}
#[Test]
public function validate_checks_label_value_row_count(): void
{
$metric = $this->validMetric();
$metric['labels'] = ['status', 'type'];
// Row has 2 items but needs 3 (2 labels + 1 value).
$metric['label_values'] = [['active', 1.0]];
$errors = $this->builder->validate($metric);
$this->assertNotEmpty($errors);
$this->assertStringContainsString('values for all labels', implode(' ', $errors));
}
// ── get_all() / get() ────────────────────────────────────────────
#[Test]
public function get_all_returns_empty_array_by_default(): void
{
$this->assertSame([], $this->builder->get_all());
}
#[Test]
public function get_all_returns_stored_metrics(): void
{
$metrics = ['id1' => ['name' => 'test_metric']];
GlobalFunctionState::$options[CustomMetricBuilder::OPTION_NAME] = $metrics;
$this->assertSame($metrics, $this->builder->get_all());
}
#[Test]
public function get_all_returns_empty_when_option_is_not_array(): void
{
GlobalFunctionState::$options[CustomMetricBuilder::OPTION_NAME] = 'not_an_array';
$this->assertSame([], $this->builder->get_all());
}
#[Test]
public function get_returns_metric_by_id(): void
{
$metric = ['id' => 'my-id', 'name' => 'test_metric', 'help' => 'Test'];
GlobalFunctionState::$options[CustomMetricBuilder::OPTION_NAME] = ['my-id' => $metric];
$this->assertSame($metric, $this->builder->get('my-id'));
}
#[Test]
public function get_returns_null_for_nonexistent_id(): void
{
$this->assertNull($this->builder->get('nonexistent'));
}
// ── save() ───────────────────────────────────────────────────────
#[Test]
public function save_creates_new_metric_and_returns_id(): void
{
$metric = $this->validMetric();
$id = $this->builder->save($metric);
$this->assertNotEmpty($id);
$this->assertGreaterThanOrEqual(1, GlobalFunctionState::getCallCount('update_option'));
$saved = GlobalFunctionState::$options[CustomMetricBuilder::OPTION_NAME];
$this->assertArrayHasKey($id, $saved);
$this->assertSame('custom_test_metric', $saved[$id]['name']);
}
#[Test]
public function save_throws_on_validation_failure(): void
{
$metric = [
'name' => '',
'help' => '',
'type' => 'gauge',
'value_type' => 'static',
];
$this->expectException(\InvalidArgumentException::class);
$this->builder->save($metric);
}
#[Test]
public function save_updates_existing_metric(): void
{
GlobalFunctionState::$options[CustomMetricBuilder::OPTION_NAME] = [
'existing-id' => [
'id' => 'existing-id',
'name' => 'custom_test_metric',
'help' => 'Original help',
'type' => 'gauge',
'value_type' => 'static',
'labels' => [],
'created_at' => 1000000,
],
];
$metric = $this->validMetric();
$metric['id'] = 'existing-id';
$metric['help'] = 'Updated help';
$id = $this->builder->save($metric);
$this->assertSame('existing-id', $id);
$saved = GlobalFunctionState::$options[CustomMetricBuilder::OPTION_NAME];
$this->assertSame('Updated help', $saved['existing-id']['help']);
}
#[Test]
public function save_sanitizes_metric_data(): void
{
$metric = $this->validMetric();
$metric['labels'] = ['valid_label'];
$metric['label_values'] = [['active', 42.5]];
$id = $this->builder->save($metric);
$saved = GlobalFunctionState::$options[CustomMetricBuilder::OPTION_NAME][$id];
$this->assertSame(['valid_label'], $saved['labels']);
$this->assertSame([['active', 42.5]], $saved['label_values']);
$this->assertIsBool($saved['enabled']);
}
// ── delete() ─────────────────────────────────────────────────────
#[Test]
public function delete_removes_metric(): void
{
GlobalFunctionState::$options[CustomMetricBuilder::OPTION_NAME] = [
'my-id' => ['id' => 'my-id', 'name' => 'custom_metric'],
];
$this->assertTrue($this->builder->delete('my-id'));
$this->assertArrayNotHasKey(
'my-id',
GlobalFunctionState::$options[CustomMetricBuilder::OPTION_NAME]
);
}
#[Test]
public function delete_returns_false_for_nonexistent(): void
{
$this->assertFalse($this->builder->delete('nonexistent'));
}
// ── export() ─────────────────────────────────────────────────────
#[Test]
public function export_returns_valid_json_with_metadata(): void
{
GlobalFunctionState::$options[CustomMetricBuilder::OPTION_NAME] = [
'id1' => ['name' => 'metric1', 'help' => 'Test 1'],
];
$json = $this->builder->export();
$data = json_decode($json, true);
$this->assertIsArray($data);
$this->assertSame(CustomMetricBuilder::EXPORT_VERSION, $data['version']);
$this->assertSame(WP_PROMETHEUS_VERSION, $data['plugin_version']);
$this->assertArrayHasKey('exported_at', $data);
$this->assertCount(1, $data['metrics']);
}
#[Test]
public function export_does_not_include_site_url(): void
{
GlobalFunctionState::$options[CustomMetricBuilder::OPTION_NAME] = [];
$json = $this->builder->export();
$data = json_decode($json, true);
$this->assertArrayNotHasKey('site_url', $data);
}
// ── import() ─────────────────────────────────────────────────────
#[Test]
public function import_rejects_oversized_json(): void
{
$json = str_repeat('x', CustomMetricBuilder::MAX_IMPORT_SIZE + 1);
$this->expectException(\InvalidArgumentException::class);
$this->expectExceptionMessage('exceeds maximum size');
$this->builder->import($json);
}
#[Test]
public function import_rejects_invalid_json(): void
{
$this->expectException(\InvalidArgumentException::class);
$this->expectExceptionMessage('Invalid JSON');
$this->builder->import('{invalid json');
}
#[Test]
public function import_rejects_missing_metrics_key(): void
{
$this->expectException(\InvalidArgumentException::class);
$this->expectExceptionMessage('No metrics found');
$this->builder->import('{"version":"1.0.0"}');
}
#[Test]
public function import_skip_mode_skips_duplicates(): void
{
GlobalFunctionState::$options[CustomMetricBuilder::OPTION_NAME] = [
'existing' => [
'id' => 'existing',
'name' => 'custom_existing_metric',
'help' => 'Test',
],
];
$json = json_encode([
'version' => '1.0.0',
'metrics' => [
[
'name' => 'custom_existing_metric',
'help' => 'Test',
'type' => 'gauge',
'value_type' => 'static',
],
],
]);
$result = $this->builder->import($json, 'skip');
$this->assertSame(1, $result['skipped']);
$this->assertSame(0, $result['imported']);
}
#[Test]
public function import_rename_mode_renames_duplicates(): void
{
GlobalFunctionState::$options[CustomMetricBuilder::OPTION_NAME] = [
'existing' => [
'id' => 'existing',
'name' => 'custom_existing_metric',
'help' => 'Existing',
'type' => 'gauge',
'value_type' => 'static',
'labels' => [],
],
];
$json = json_encode([
'version' => '1.0.0',
'metrics' => [
[
'name' => 'custom_existing_metric',
'help' => 'Imported',
'type' => 'gauge',
'value_type' => 'static',
'labels' => [],
],
],
]);
$result = $this->builder->import($json, 'rename');
$this->assertSame(1, $result['imported']);
$all = $this->builder->get_all();
$names = array_column($all, 'name');
$this->assertContains('custom_existing_metric', $names);
$this->assertContains('custom_existing_metric_imported_1', $names);
}
#[Test]
public function import_invalid_mode_defaults_to_skip(): void
{
GlobalFunctionState::$options[CustomMetricBuilder::OPTION_NAME] = [
'existing' => [
'id' => 'existing',
'name' => 'custom_existing_metric',
'help' => 'Test',
],
];
$json = json_encode([
'version' => '1.0.0',
'metrics' => [
[
'name' => 'custom_existing_metric',
'help' => 'Test',
'type' => 'gauge',
'value_type' => 'static',
],
],
]);
$result = $this->builder->import($json, 'invalid_mode');
$this->assertSame(1, $result['skipped']);
}
#[Test]
public function import_counts_metrics_without_name_as_errors(): void
{
$json = json_encode([
'version' => '1.0.0',
'metrics' => [['help' => 'No name']],
]);
$result = $this->builder->import($json);
$this->assertSame(1, $result['errors']);
$this->assertSame(0, $result['imported']);
}
#[Test]
public function import_successfully_imports_new_metric(): void
{
$json = json_encode([
'version' => '1.0.0',
'metrics' => [
[
'name' => 'custom_new_metric',
'help' => 'A new metric',
'type' => 'gauge',
'value_type' => 'static',
'labels' => [],
],
],
]);
$result = $this->builder->import($json);
$this->assertSame(1, $result['imported']);
$this->assertSame(0, $result['skipped']);
$this->assertSame(0, $result['errors']);
$all = $this->builder->get_all();
$names = array_column($all, 'name');
$this->assertContains('custom_new_metric', $names);
}
// ── Constants ────────────────────────────────────────────────────
#[Test]
public function constants_are_defined(): void
{
$this->assertSame('wp_prometheus_custom_metrics', CustomMetricBuilder::OPTION_NAME);
$this->assertSame(5, CustomMetricBuilder::MAX_LABELS);
$this->assertSame(50, CustomMetricBuilder::MAX_LABEL_VALUES);
$this->assertSame(1048576, CustomMetricBuilder::MAX_IMPORT_SIZE);
}
// ── Helpers ──────────────────────────────────────────────────────
private function validMetric(): array
{
return [
'name' => 'custom_test_metric',
'help' => 'A test metric',
'type' => 'gauge',
'value_type' => 'static',
'labels' => [],
'label_values' => [],
'enabled' => true,
];
}
}

View File

@@ -0,0 +1,234 @@
<?php
declare(strict_types=1);
namespace Magdev\WpPrometheus\Tests\Unit\Metrics;
use Magdev\WpPrometheus\Metrics\RuntimeCollector;
use Magdev\WpPrometheus\Tests\Helpers\GlobalFunctionState;
use Magdev\WpPrometheus\Tests\Unit\TestCase;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\Attributes\Test;
#[CoversClass(RuntimeCollector::class)]
class RuntimeCollectorTest extends TestCase
{
private RuntimeCollector $collector;
private array $originalServer = [];
protected function setUp(): void
{
parent::setUp();
$this->originalServer = $_SERVER;
$this->collector = $this->createInstance();
}
protected function tearDown(): void
{
$_SERVER = $this->originalServer;
$this->resetSingleton();
parent::tearDown();
}
// ── Singleton ────────────────────────────────────────────────────
#[Test]
public function get_instance_returns_singleton(): void
{
$instance1 = RuntimeCollector::get_instance();
$instance2 = RuntimeCollector::get_instance();
$this->assertSame($instance1, $instance2);
}
#[Test]
public function get_instance_returns_runtime_collector(): void
{
$instance = RuntimeCollector::get_instance();
$this->assertInstanceOf(RuntimeCollector::class, $instance);
}
// ── get_stored_metrics() ─────────────────────────────────────────
#[Test]
public function get_stored_metrics_returns_default_when_empty(): void
{
$metrics = $this->collector->get_stored_metrics();
$this->assertSame([], $metrics);
}
#[Test]
public function get_stored_metrics_returns_stored_data(): void
{
$stored = [
'counters' => ['key' => ['name' => 'test', 'value' => 5]],
'histograms' => [],
'last_reset' => 1000000,
];
GlobalFunctionState::$options['wp_prometheus_runtime_metrics'] = $stored;
$metrics = $this->collector->get_stored_metrics();
$this->assertSame($stored, $metrics);
}
#[Test]
public function get_stored_metrics_returns_default_for_non_array(): void
{
GlobalFunctionState::$options['wp_prometheus_runtime_metrics'] = 'invalid';
$metrics = $this->collector->get_stored_metrics();
$this->assertArrayHasKey('counters', $metrics);
$this->assertArrayHasKey('histograms', $metrics);
$this->assertArrayHasKey('last_reset', $metrics);
}
// ── reset_metrics() ──────────────────────────────────────────────
#[Test]
public function reset_metrics_deletes_option(): void
{
GlobalFunctionState::$options['wp_prometheus_runtime_metrics'] = ['data'];
RuntimeCollector::reset_metrics();
$this->assertSame(1, GlobalFunctionState::getCallCount('delete_option'));
$this->assertArrayNotHasKey(
'wp_prometheus_runtime_metrics',
GlobalFunctionState::$options
);
}
// ── get_duration_buckets() ───────────────────────────────────────
#[Test]
public function get_duration_buckets_returns_expected_values(): void
{
$buckets = RuntimeCollector::get_duration_buckets();
$this->assertIsArray($buckets);
$this->assertCount(11, $buckets);
$this->assertSame(0.005, $buckets[0]);
$this->assertEquals(10, end($buckets));
}
#[Test]
public function duration_buckets_are_in_ascending_order(): void
{
$buckets = RuntimeCollector::get_duration_buckets();
$sorted = $buckets;
sort($sorted);
$this->assertSame($sorted, $buckets);
}
// ── get_normalized_endpoint() (private, via reflection) ──────────
#[Test]
public function normalized_endpoint_returns_admin_when_is_admin(): void
{
$_SERVER['REQUEST_URI'] = '/wp-admin/options-general.php';
GlobalFunctionState::$options['__is_admin'] = true;
$result = $this->callPrivateMethod('get_normalized_endpoint');
$this->assertSame('admin', $result);
}
#[Test]
public function normalized_endpoint_returns_ajax_when_doing_ajax(): void
{
$_SERVER['REQUEST_URI'] = '/wp-admin/admin-ajax.php';
GlobalFunctionState::$options['__wp_doing_ajax'] = true;
$result = $this->callPrivateMethod('get_normalized_endpoint');
$this->assertSame('ajax', $result);
}
#[Test]
public function normalized_endpoint_returns_cron_when_doing_cron(): void
{
// Use a generic URI (not /wp-cron.php) to ensure function is checked, not URL pattern.
$_SERVER['REQUEST_URI'] = '/some-page';
GlobalFunctionState::$options['__wp_doing_cron'] = true;
$result = $this->callPrivateMethod('get_normalized_endpoint');
$this->assertSame('cron', $result);
}
#[Test]
#[DataProvider('urlEndpointProvider')]
public function normalized_endpoint_from_url_pattern(string $uri, string $expected): void
{
$_SERVER['REQUEST_URI'] = $uri;
$result = $this->callPrivateMethod('get_normalized_endpoint');
$this->assertSame($expected, $result);
}
public static function urlEndpointProvider(): array
{
return [
'rest api' => ['/wp-json/wp/v2/posts', 'rest-api'],
'login page' => ['/wp-login.php', 'login'],
'login with query' => ['/wp-login.php?action=login', 'login'],
'wp-cron' => ['/wp-cron.php', 'cron'],
'feed root' => ['/feed/', 'feed'],
'feed trailing' => ['/category/news/feed', 'feed'],
'feed with slash' => ['/feed', 'feed'],
'homepage' => ['/', 'frontend'],
'page' => ['/about-us', 'frontend'],
'post' => ['/2024/01/hello-world', 'frontend'],
];
}
// ── is_metrics_request() (private, via reflection) ───────────────
#[Test]
public function is_metrics_request_true_for_metrics_uri(): void
{
$_SERVER['REQUEST_URI'] = '/metrics';
$this->assertTrue($this->callPrivateMethod('is_metrics_request'));
}
#[Test]
public function is_metrics_request_true_with_trailing_slash(): void
{
$_SERVER['REQUEST_URI'] = '/metrics/';
$this->assertTrue($this->callPrivateMethod('is_metrics_request'));
}
#[Test]
public function is_metrics_request_false_for_other_uri(): void
{
$_SERVER['REQUEST_URI'] = '/some-page';
$this->assertFalse($this->callPrivateMethod('is_metrics_request'));
}
#[Test]
public function is_metrics_request_false_when_no_uri(): void
{
unset($_SERVER['REQUEST_URI']);
$this->assertFalse($this->callPrivateMethod('is_metrics_request'));
}
// ── Helpers ──────────────────────────────────────────────────────
private function createInstance(): RuntimeCollector
{
$reflection = new \ReflectionClass(RuntimeCollector::class);
return $reflection->newInstanceWithoutConstructor();
}
private function resetSingleton(): void
{
$reflection = new \ReflectionClass(RuntimeCollector::class);
$property = $reflection->getProperty('instance');
$property->setValue(null, null);
}
private function callPrivateMethod(string $method, array $args = []): mixed
{
$reflection = new \ReflectionMethod($this->collector, $method);
return $reflection->invoke($this->collector, ...$args);
}
}

View File

@@ -0,0 +1,411 @@
<?php
declare(strict_types=1);
namespace Magdev\WpPrometheus\Tests\Unit\Metrics;
use Magdev\WpPrometheus\Metrics\StorageFactory;
use Magdev\WpPrometheus\Tests\Helpers\GlobalFunctionState;
use Magdev\WpPrometheus\Tests\Unit\TestCase;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\Test;
use Prometheus\Storage\InMemory;
#[CoversClass(StorageFactory::class)]
class StorageFactoryTest extends TestCase
{
/** @var list<string> Environment variables to clean up after each test. */
private array $envVarsToClean = [];
protected function setUp(): void
{
parent::setUp();
StorageFactory::reset();
}
protected function tearDown(): void
{
StorageFactory::reset();
foreach ($this->envVarsToClean as $var) {
putenv($var);
}
$this->envVarsToClean = [];
parent::tearDown();
}
// ── Adapter Constants ────────────────────────────────────────────
#[Test]
public function adapter_constants_are_defined(): void
{
$this->assertSame('inmemory', StorageFactory::ADAPTER_INMEMORY);
$this->assertSame('redis', StorageFactory::ADAPTER_REDIS);
$this->assertSame('apcu', StorageFactory::ADAPTER_APCU);
}
// ── get_available_adapters() ─────────────────────────────────────
#[Test]
public function get_available_adapters_returns_all_three(): void
{
$adapters = StorageFactory::get_available_adapters();
$this->assertArrayHasKey(StorageFactory::ADAPTER_INMEMORY, $adapters);
$this->assertArrayHasKey(StorageFactory::ADAPTER_REDIS, $adapters);
$this->assertArrayHasKey(StorageFactory::ADAPTER_APCU, $adapters);
$this->assertCount(3, $adapters);
}
// ── is_adapter_available() ───────────────────────────────────────
#[Test]
public function inmemory_adapter_is_always_available(): void
{
$this->assertTrue(StorageFactory::is_adapter_available(StorageFactory::ADAPTER_INMEMORY));
}
#[Test]
public function unknown_adapter_is_not_available(): void
{
$this->assertFalse(StorageFactory::is_adapter_available('unknown'));
}
#[Test]
public function redis_availability_depends_on_extension(): void
{
$extensionLoaded = $this->getFunctionMock(
'Magdev\\WpPrometheus\\Metrics',
'extension_loaded'
);
$extensionLoaded->expects($this->any())->willReturn(false);
$this->assertFalse(StorageFactory::is_adapter_available(StorageFactory::ADAPTER_REDIS));
}
#[Test]
public function apcu_availability_requires_extension_and_enabled(): void
{
$extensionLoaded = $this->getFunctionMock(
'Magdev\\WpPrometheus\\Metrics',
'extension_loaded'
);
$extensionLoaded->expects($this->any())->willReturn(true);
$apcuEnabled = $this->getFunctionMock(
'Magdev\\WpPrometheus\\Metrics',
'apcu_enabled'
);
$apcuEnabled->expects($this->any())->willReturn(false);
$this->assertFalse(StorageFactory::is_adapter_available(StorageFactory::ADAPTER_APCU));
}
// ── get_configured_adapter() ─────────────────────────────────────
#[Test]
public function default_configured_adapter_is_inmemory(): void
{
$this->assertSame('inmemory', StorageFactory::get_configured_adapter());
}
#[Test]
public function configured_adapter_reads_from_env_var(): void
{
$this->setEnv('WP_PROMETHEUS_STORAGE_ADAPTER', 'redis');
$this->assertSame('redis', StorageFactory::get_configured_adapter());
}
#[Test]
public function configured_adapter_reads_from_option(): void
{
GlobalFunctionState::$options['wp_prometheus_storage_adapter'] = 'apcu';
$this->assertSame('apcu', StorageFactory::get_configured_adapter());
}
#[Test]
public function env_var_takes_precedence_over_option(): void
{
$this->setEnv('WP_PROMETHEUS_STORAGE_ADAPTER', 'redis');
GlobalFunctionState::$options['wp_prometheus_storage_adapter'] = 'apcu';
$this->assertSame('redis', StorageFactory::get_configured_adapter());
}
#[Test]
public function configured_adapter_lowercases_env_value(): void
{
$this->setEnv('WP_PROMETHEUS_STORAGE_ADAPTER', 'REDIS');
$this->assertSame('redis', StorageFactory::get_configured_adapter());
}
// ── get_redis_config() ───────────────────────────────────────────
#[Test]
public function get_redis_config_returns_defaults(): void
{
$config = StorageFactory::get_redis_config();
$this->assertSame('127.0.0.1', $config['host']);
$this->assertSame(6379, $config['port']);
$this->assertSame('', $config['password']);
$this->assertSame(0, $config['database']);
$this->assertSame('WORDPRESS_PROMETHEUS_', $config['prefix']);
}
#[Test]
public function get_redis_config_reads_from_env_vars(): void
{
$this->setEnv('WP_PROMETHEUS_REDIS_HOST', '10.0.0.1');
$this->setEnv('WP_PROMETHEUS_REDIS_PORT', '6380');
$this->setEnv('WP_PROMETHEUS_REDIS_PASSWORD', 's3cret');
$this->setEnv('WP_PROMETHEUS_REDIS_DATABASE', '2');
$this->setEnv('WP_PROMETHEUS_REDIS_PREFIX', 'MY_PREFIX_');
$config = StorageFactory::get_redis_config();
$this->assertSame('10.0.0.1', $config['host']);
$this->assertSame(6380, $config['port']);
$this->assertSame('s3cret', $config['password']);
$this->assertSame(2, $config['database']);
$this->assertSame('MY_PREFIX_', $config['prefix']);
}
#[Test]
public function get_redis_config_reads_from_option(): void
{
GlobalFunctionState::$options['wp_prometheus_redis_config'] = [
'host' => '192.168.1.1',
'port' => 6381,
'password' => 'optpass',
'database' => 3,
'prefix' => 'OPT_',
];
$config = StorageFactory::get_redis_config();
$this->assertSame('192.168.1.1', $config['host']);
$this->assertSame(6381, $config['port']);
$this->assertSame('optpass', $config['password']);
$this->assertSame(3, $config['database']);
$this->assertSame('OPT_', $config['prefix']);
}
#[Test]
public function get_redis_config_env_takes_precedence(): void
{
$this->setEnv('WP_PROMETHEUS_REDIS_HOST', 'env-host');
GlobalFunctionState::$options['wp_prometheus_redis_config'] = [
'host' => 'option-host',
];
$config = StorageFactory::get_redis_config();
$this->assertSame('env-host', $config['host']);
}
// ── get_apcu_prefix() ────────────────────────────────────────────
#[Test]
public function get_apcu_prefix_returns_default(): void
{
$this->assertSame('wp_prom', StorageFactory::get_apcu_prefix());
}
#[Test]
public function get_apcu_prefix_reads_from_env(): void
{
$this->setEnv('WP_PROMETHEUS_APCU_PREFIX', 'custom_prefix');
$this->assertSame('custom_prefix', StorageFactory::get_apcu_prefix());
}
#[Test]
public function get_apcu_prefix_reads_from_option(): void
{
GlobalFunctionState::$options['wp_prometheus_apcu_prefix'] = 'opt_prefix';
$this->assertSame('opt_prefix', StorageFactory::get_apcu_prefix());
}
// ── save_config() ────────────────────────────────────────────────
#[Test]
public function save_config_stores_adapter(): void
{
StorageFactory::save_config(['adapter' => 'redis']);
$this->assertSame(
'redis',
GlobalFunctionState::$options['wp_prometheus_storage_adapter']
);
}
#[Test]
public function save_config_stores_redis_config(): void
{
StorageFactory::save_config([
'redis' => [
'host' => '10.0.0.5',
'port' => 6390,
'password' => 'pass123',
'database' => 1,
'prefix' => 'TEST_',
],
]);
$saved = GlobalFunctionState::$options['wp_prometheus_redis_config'];
$this->assertSame('10.0.0.5', $saved['host']);
$this->assertSame(6390, $saved['port']);
$this->assertSame('pass123', $saved['password']);
$this->assertSame(1, $saved['database']);
// sanitize_key lowercases the prefix
$this->assertSame('test_', $saved['prefix']);
}
#[Test]
public function save_config_stores_apcu_prefix(): void
{
StorageFactory::save_config(['apcu_prefix' => 'my_apcu']);
$this->assertSame(
'my_apcu',
GlobalFunctionState::$options['wp_prometheus_apcu_prefix']
);
}
#[Test]
public function save_config_resets_singleton(): void
{
// Get an adapter (creates singleton).
$adapter1 = StorageFactory::get_adapter();
// Save new config (should reset singleton).
StorageFactory::save_config(['adapter' => 'inmemory']);
// Get adapter again — should be a new instance.
$adapter2 = StorageFactory::get_adapter();
$this->assertNotSame($adapter1, $adapter2);
}
// ── test_connection() ────────────────────────────────────────────
#[Test]
public function test_connection_inmemory_always_succeeds(): void
{
$result = StorageFactory::test_connection(StorageFactory::ADAPTER_INMEMORY);
$this->assertTrue($result['success']);
$this->assertNotEmpty($result['message']);
}
#[Test]
public function test_connection_unknown_adapter_fails(): void
{
$result = StorageFactory::test_connection('unknown');
$this->assertFalse($result['success']);
$this->assertStringContainsString('Unknown', $result['message']);
}
#[Test]
public function test_connection_redis_fails_without_extension(): void
{
$extensionLoaded = $this->getFunctionMock(
'Magdev\\WpPrometheus\\Metrics',
'extension_loaded'
);
$extensionLoaded->expects($this->any())->willReturn(false);
$result = StorageFactory::test_connection(StorageFactory::ADAPTER_REDIS);
$this->assertFalse($result['success']);
$this->assertStringContainsString('not installed', $result['message']);
}
#[Test]
public function test_connection_apcu_fails_without_extension(): void
{
$extensionLoaded = $this->getFunctionMock(
'Magdev\\WpPrometheus\\Metrics',
'extension_loaded'
);
$extensionLoaded->expects($this->any())->willReturn(false);
$result = StorageFactory::test_connection(StorageFactory::ADAPTER_APCU);
$this->assertFalse($result['success']);
$this->assertStringContainsString('not installed', $result['message']);
}
// ── get_adapter() / reset() / singleton ──────────────────────────
#[Test]
public function get_adapter_returns_inmemory_by_default(): void
{
$adapter = StorageFactory::get_adapter();
$this->assertInstanceOf(InMemory::class, $adapter);
}
#[Test]
public function get_adapter_returns_singleton(): void
{
$adapter1 = StorageFactory::get_adapter();
$adapter2 = StorageFactory::get_adapter();
$this->assertSame($adapter1, $adapter2);
}
#[Test]
public function reset_clears_singleton_and_error(): void
{
StorageFactory::get_adapter();
StorageFactory::reset();
// After reset, get_last_error should be empty.
$this->assertEmpty(StorageFactory::get_last_error());
// Getting adapter again creates a new instance.
$adapter = StorageFactory::get_adapter();
$this->assertInstanceOf(InMemory::class, $adapter);
}
#[Test]
public function get_adapter_falls_back_to_inmemory_when_redis_unavailable(): void
{
$this->setEnv('WP_PROMETHEUS_STORAGE_ADAPTER', 'redis');
$extensionLoaded = $this->getFunctionMock(
'Magdev\\WpPrometheus\\Metrics',
'extension_loaded'
);
$extensionLoaded->expects($this->any())->willReturn(false);
$adapter = StorageFactory::get_adapter();
$this->assertInstanceOf(InMemory::class, $adapter);
$this->assertNotEmpty(StorageFactory::get_last_error());
}
#[Test]
public function get_adapter_falls_back_to_inmemory_when_apcu_unavailable(): void
{
$this->setEnv('WP_PROMETHEUS_STORAGE_ADAPTER', 'apcu');
$extensionLoaded = $this->getFunctionMock(
'Magdev\\WpPrometheus\\Metrics',
'extension_loaded'
);
$extensionLoaded->expects($this->any())->willReturn(false);
$adapter = StorageFactory::get_adapter();
$this->assertInstanceOf(InMemory::class, $adapter);
$this->assertNotEmpty(StorageFactory::get_last_error());
}
// ── Helpers ──────────────────────────────────────────────────────
private function setEnv(string $name, string $value): void
{
putenv("$name=$value");
$this->envVarsToClean[] = $name;
}
}

32
tests/Unit/TestCase.php Normal file
View File

@@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
namespace Magdev\WpPrometheus\Tests\Unit;
use Magdev\WpPrometheus\Tests\Helpers\GlobalFunctionState;
use phpmock\phpunit\PHPMock;
use PHPUnit\Framework\TestCase as PHPUnitTestCase;
/**
* Base test case for WP Prometheus unit tests.
*
* Provides the PHPMock trait for mocking WordPress functions
* called from namespaced code, and resets global state between tests.
*/
abstract class TestCase extends PHPUnitTestCase
{
use PHPMock;
protected function setUp(): void
{
parent::setUp();
GlobalFunctionState::reset();
}
protected function tearDown(): void
{
GlobalFunctionState::reset();
parent::tearDown();
}
}

450
tests/bootstrap.php Normal file
View File

@@ -0,0 +1,450 @@
<?php
/**
* PHPUnit bootstrap file for WP Prometheus tests.
*
* Defines WordPress constants and global function stubs required
* for loading plugin source files without a WordPress environment.
*
* @package WP_Prometheus\Tests
*/
declare(strict_types=1);
use Magdev\WpPrometheus\Tests\Helpers\GlobalFunctionState;
// 1. Load Composer autoloader first (for GlobalFunctionState class).
require_once dirname(__DIR__) . '/vendor/autoload.php';
// 2. Define WordPress constants required by source files.
define('ABSPATH', '/tmp/wordpress/');
define('WP_CONTENT_DIR', '/tmp/wordpress/wp-content');
define('WP_PROMETHEUS_VERSION', '0.5.0');
define('WP_PROMETHEUS_FILE', dirname(__DIR__) . '/wp-prometheus.php');
define('WP_PROMETHEUS_PATH', dirname(__DIR__) . '/');
define('WP_PROMETHEUS_URL', 'https://example.com/wp-content/plugins/wp-prometheus/');
define('WP_PROMETHEUS_BASENAME', 'wp-prometheus/wp-prometheus.php');
// 3. Define global WordPress function stubs.
// These exist so plugin files can be require'd without fatal errors.
// Per-test behavior in namespaced code is controlled via php-mock.
// Global-scope tests use GlobalFunctionState for controllable behavior.
// -- Translation functions --
if (!function_exists('__')) {
function __(string $text, string $domain = 'default'): string
{
return $text;
}
}
if (!function_exists('_e')) {
function _e(string $text, string $domain = 'default'): void
{
echo $text;
}
}
if (!function_exists('esc_html__')) {
function esc_html__(string $text, string $domain = 'default'): string
{
return htmlspecialchars($text, ENT_QUOTES, 'UTF-8');
}
}
if (!function_exists('esc_html_e')) {
function esc_html_e(string $text, string $domain = 'default'): void
{
echo htmlspecialchars($text, ENT_QUOTES, 'UTF-8');
}
}
// -- Escaping functions --
if (!function_exists('esc_html')) {
function esc_html(string $text): string
{
return htmlspecialchars($text, ENT_QUOTES, 'UTF-8');
}
}
if (!function_exists('esc_attr')) {
function esc_attr(string $text): string
{
return htmlspecialchars($text, ENT_QUOTES, 'UTF-8');
}
}
if (!function_exists('esc_url')) {
function esc_url(string $url): string
{
return filter_var($url, FILTER_SANITIZE_URL) ?: '';
}
}
if (!function_exists('esc_url_raw')) {
function esc_url_raw(string $url): string
{
return filter_var($url, FILTER_SANITIZE_URL) ?: '';
}
}
// -- Sanitization functions --
if (!function_exists('sanitize_text_field')) {
function sanitize_text_field(string $str): string
{
return trim(strip_tags($str));
}
}
if (!function_exists('sanitize_key')) {
function sanitize_key(string $key): string
{
return preg_replace('/[^a-z0-9_\-]/', '', strtolower($key));
}
}
if (!function_exists('sanitize_file_name')) {
function sanitize_file_name(string $name): string
{
return preg_replace('/[^a-zA-Z0-9_.\-]/', '', $name);
}
}
if (!function_exists('sanitize_html_class')) {
function sanitize_html_class(string $class): string
{
return preg_replace('/[^a-zA-Z0-9_\-]/', '', $class);
}
}
if (!function_exists('absint')) {
function absint($value): int
{
return abs((int) $value);
}
}
if (!function_exists('wp_unslash')) {
function wp_unslash($value)
{
return is_string($value) ? stripslashes($value) : $value;
}
}
// -- WordPress utility functions --
if (!function_exists('wp_parse_url')) {
function wp_parse_url(string $url, int $component = -1)
{
return parse_url($url, $component);
}
}
if (!function_exists('wp_json_encode')) {
function wp_json_encode($data, int $options = 0, int $depth = 512)
{
return json_encode($data, $options, $depth);
}
}
if (!function_exists('wp_generate_uuid4')) {
function wp_generate_uuid4(): string
{
return sprintf(
'%04x%04x-%04x-%04x-%04x-%04x%04x%04x',
mt_rand(0, 0xffff), mt_rand(0, 0xffff),
mt_rand(0, 0xffff),
mt_rand(0, 0x0fff) | 0x4000,
mt_rand(0, 0x3fff) | 0x8000,
mt_rand(0, 0xffff), mt_rand(0, 0xffff), mt_rand(0, 0xffff)
);
}
}
if (!function_exists('wp_generate_password')) {
function wp_generate_password(int $length = 12, bool $special = true): string
{
return substr(bin2hex(random_bytes($length)), 0, $length);
}
}
if (!function_exists('size_format')) {
function size_format(int $bytes, int $decimals = 0): string
{
$units = ['B', 'KB', 'MB', 'GB', 'TB'];
$power = $bytes > 0 ? floor(log($bytes, 1024)) : 0;
return number_format($bytes / pow(1024, $power), $decimals) . ' ' . $units[$power];
}
}
if (!function_exists('path_is_absolute')) {
function path_is_absolute(string $path): bool
{
return str_starts_with($path, '/') || (bool) preg_match('#^[a-zA-Z]:\\\\#', $path);
}
}
// -- Hook functions (no-ops) --
if (!function_exists('add_action')) {
function add_action(string $hook, $callback, int $priority = 10, int $accepted_args = 1): bool
{
return true;
}
}
if (!function_exists('add_filter')) {
function add_filter(string $hook, $callback, int $priority = 10, int $accepted_args = 1): bool
{
return true;
}
}
if (!function_exists('remove_all_filters')) {
function remove_all_filters(string $hook, $priority = false): bool
{
return true;
}
}
if (!function_exists('do_action')) {
function do_action(string $hook, ...$args): void
{
}
}
if (!function_exists('apply_filters')) {
function apply_filters(string $hook, $value, ...$args)
{
return $value;
}
}
// -- Option functions (controllable via GlobalFunctionState) --
if (!function_exists('get_option')) {
function get_option(string $option, $default = false)
{
if (array_key_exists($option, GlobalFunctionState::$options)) {
return GlobalFunctionState::$options[$option];
}
return $default;
}
}
if (!function_exists('update_option')) {
function update_option(string $option, $value, $autoload = null): bool
{
GlobalFunctionState::recordCall('update_option', $option, $value);
GlobalFunctionState::$options[$option] = $value;
return true;
}
}
if (!function_exists('delete_option')) {
function delete_option(string $option): bool
{
GlobalFunctionState::recordCall('delete_option', $option);
unset(GlobalFunctionState::$options[$option]);
return true;
}
}
if (!function_exists('delete_transient')) {
function delete_transient(string $transient): bool
{
GlobalFunctionState::recordCall('delete_transient', $transient);
return true;
}
}
if (!function_exists('flush_rewrite_rules')) {
function flush_rewrite_rules(bool $hard = true): void
{
GlobalFunctionState::recordCall('flush_rewrite_rules');
}
}
// -- URL functions --
if (!function_exists('home_url')) {
function home_url(string $path = ''): string
{
return 'https://example.com' . $path;
}
}
if (!function_exists('admin_url')) {
function admin_url(string $path = ''): string
{
return 'https://example.com/wp-admin/' . $path;
}
}
// -- Conditional functions (controllable via GlobalFunctionState) --
if (!function_exists('is_admin')) {
function is_admin(): bool
{
return GlobalFunctionState::$options['__is_admin'] ?? false;
}
}
if (!function_exists('wp_doing_ajax')) {
function wp_doing_ajax(): bool
{
return GlobalFunctionState::$options['__wp_doing_ajax'] ?? false;
}
}
if (!function_exists('wp_doing_cron')) {
function wp_doing_cron(): bool
{
return GlobalFunctionState::$options['__wp_doing_cron'] ?? false;
}
}
if (!function_exists('is_multisite')) {
function is_multisite(): bool
{
return false;
}
}
// -- Plugin functions --
if (!function_exists('load_plugin_textdomain')) {
function load_plugin_textdomain(string $domain, $deprecated = false, string $path = ''): bool
{
return true;
}
}
if (!function_exists('register_activation_hook')) {
function register_activation_hook(string $file, $callback): void
{
}
}
if (!function_exists('register_deactivation_hook')) {
function register_deactivation_hook(string $file, $callback): void
{
}
}
// -- Rewrite functions --
if (!function_exists('add_rewrite_rule')) {
function add_rewrite_rule(string $regex, string $redirect, string $after = 'bottom'): void
{
GlobalFunctionState::recordCall('add_rewrite_rule', $regex, $redirect, $after);
}
}
if (!function_exists('add_rewrite_tag')) {
function add_rewrite_tag(string $tag, string $regex, string $query = ''): void
{
GlobalFunctionState::recordCall('add_rewrite_tag', $tag, $regex, $query);
}
}
// -- HTTP functions --
if (!function_exists('status_header')) {
function status_header(int $code, string $description = ''): void
{
}
}
if (!function_exists('hash_equals')) {
// hash_equals is a PHP built-in, but define stub just in case.
}
if (!function_exists('wp_rand')) {
function wp_rand(int $min = 0, int $max = 0): int
{
return random_int($min, max($min, $max ?: PHP_INT_MAX >> 1));
}
}
if (!function_exists('get_bloginfo')) {
function get_bloginfo(string $show = '', bool $filter = true): string
{
return match ($show) {
'version' => '6.7',
'language' => 'en-US',
'name' => 'Test Site',
default => '',
};
}
}
if (!function_exists('current_user_can')) {
function current_user_can(string $capability, ...$args): bool
{
return true;
}
}
if (!function_exists('deactivate_plugins')) {
function deactivate_plugins($plugins, bool $silent = false, $network_wide = null): void
{
}
}
if (!function_exists('wp_die')) {
function wp_die($message = '', $title = '', $args = []): void
{
throw new \RuntimeException((string) $message);
}
}
// -- Plugin global authentication functions (from wp-prometheus.php) --
// Cannot include wp-prometheus.php directly due to constant definitions
// and side effects. These mirror the production code for testing.
if (!function_exists('wp_prometheus_get_authorization_header')) {
function wp_prometheus_get_authorization_header(): string
{
if (isset($_SERVER['HTTP_AUTHORIZATION'])) {
return sanitize_text_field(wp_unslash($_SERVER['HTTP_AUTHORIZATION']));
}
if (isset($_SERVER['REDIRECT_HTTP_AUTHORIZATION'])) {
return sanitize_text_field(wp_unslash($_SERVER['REDIRECT_HTTP_AUTHORIZATION']));
}
if (function_exists('apache_request_headers')) {
$headers = apache_request_headers();
if (isset($headers['Authorization'])) {
return sanitize_text_field($headers['Authorization']);
}
}
return '';
}
}
// -- WordPress core class stubs --
if (!class_exists('WP')) {
class WP
{
public array $query_vars = [];
}
}
if (!function_exists('wp_prometheus_authenticate_request')) {
function wp_prometheus_authenticate_request(): bool
{
$auth_token = get_option('wp_prometheus_auth_token', '');
// If no token is set, deny access.
if (empty($auth_token)) {
return false;
}
// Check for Bearer token in Authorization header.
$auth_header = wp_prometheus_get_authorization_header();
if (!empty($auth_header) && preg_match('/Bearer\s+(.*)$/i', $auth_header, $matches)) {
return hash_equals($auth_token, $matches[1]);
}
// Check for token in query parameter.
if (isset($_GET['token']) && hash_equals($auth_token, sanitize_text_field(wp_unslash($_GET['token'])))) {
return true;
}
return false;
}
}

View File

@@ -3,7 +3,7 @@
* Plugin Name: WP Prometheus * Plugin Name: WP Prometheus
* Plugin URI: https://src.bundespruefstelle.ch/magdev/wp-prometheus * Plugin URI: https://src.bundespruefstelle.ch/magdev/wp-prometheus
* Description: Prometheus metrics endpoint for WordPress with extensible hooks for custom metrics. * Description: Prometheus metrics endpoint for WordPress with extensible hooks for custom metrics.
* Version: 0.4.3 * Version: 0.5.0
* Requires at least: 6.4 * Requires at least: 6.4
* Requires PHP: 8.3 * Requires PHP: 8.3
* Author: Marco Graetsch * Author: Marco Graetsch
@@ -22,11 +22,16 @@ if ( ! defined( 'ABSPATH' ) ) {
} }
/** /**
* Early metrics endpoint handler. * Early metrics request detection.
* *
* Intercepts /metrics requests before full WordPress initialization to avoid * Detects /metrics requests early and removes problematic content filters
* conflicts with other plugins that may cause issues during template loading. * to prevent recursion issues with Twig-based plugins. Unlike the previous
* This runs at plugin load time, before plugins_loaded hook. * "early mode", this allows WordPress to continue loading so that third-party
* plugins can register their wp_prometheus_collect_metrics hooks.
*
* Two modes are available:
* - Safe mode (default): Removes filters early, lets WP load, fires custom hooks
* - Isolated mode: Outputs metrics immediately without custom hooks (legacy early mode)
*/ */
function wp_prometheus_early_metrics_check(): void { function wp_prometheus_early_metrics_check(): void {
// Only handle /metrics requests. // Only handle /metrics requests.
@@ -37,18 +42,58 @@ function wp_prometheus_early_metrics_check(): void {
return; return;
} }
// Check if early mode is disabled via environment variable. // Set flag to indicate we're handling a metrics request.
$env_disable = getenv( 'WP_PROMETHEUS_DISABLE_EARLY_MODE' ); define( 'WP_PROMETHEUS_METRICS_REQUEST', true );
if ( false !== $env_disable && in_array( strtolower( $env_disable ), array( '1', 'true', 'yes', 'on' ), true ) ) {
return; // Check if isolated mode is enabled via environment variable.
$env_isolated = getenv( 'WP_PROMETHEUS_ISOLATED_MODE' );
$isolated_mode = false !== $env_isolated && in_array( strtolower( $env_isolated ), array( '1', 'true', 'yes', 'on' ), true );
// Check if isolated mode is enabled via option (legacy "early mode" setting).
if ( ! $isolated_mode && ! get_option( 'wp_prometheus_disable_early_mode', false ) ) {
// Check for legacy isolated mode option.
$isolated_mode = (bool) get_option( 'wp_prometheus_isolated_mode', false );
} }
// Check if early mode is disabled via option. // Remove all content filters immediately to prevent recursion with Twig-based plugins.
// We can use get_option() here because WordPress core is already loaded. // This is done for BOTH safe mode and isolated mode.
if ( get_option( 'wp_prometheus_disable_early_mode', false ) ) { add_action( 'plugins_loaded', 'wp_prometheus_remove_content_filters', 0 );
return;
}
// Also remove filters now in case they were added by mu-plugins.
wp_prometheus_remove_content_filters();
// If isolated mode is enabled, handle metrics immediately without waiting for plugins.
if ( $isolated_mode ) {
wp_prometheus_isolated_metrics_handler();
}
}
/**
* Remove content filters that can cause recursion.
*
* Called early during metrics requests to prevent infinite loops
* with Twig-based plugins that hook into content filters.
*
* @return void
*/
function wp_prometheus_remove_content_filters(): void {
remove_all_filters( 'the_content' );
remove_all_filters( 'the_excerpt' );
remove_all_filters( 'get_the_excerpt' );
remove_all_filters( 'the_title' );
remove_all_filters( 'the_content_feed' );
remove_all_filters( 'comment_text' );
}
/**
* Handle metrics in isolated mode (no custom hooks).
*
* This is the legacy "early mode" that outputs metrics immediately
* without allowing third-party plugins to add custom metrics.
*
* @return void
*/
function wp_prometheus_isolated_metrics_handler(): void {
// Check if autoloader exists. // Check if autoloader exists.
$autoloader = __DIR__ . '/vendor/autoload.php'; $autoloader = __DIR__ . '/vendor/autoload.php';
if ( ! file_exists( $autoloader ) ) { if ( ! file_exists( $autoloader ) ) {
@@ -62,9 +107,8 @@ function wp_prometheus_early_metrics_check(): void {
return; // Let normal flow handle unlicensed state. return; // Let normal flow handle unlicensed state.
} }
// Authenticate. // Authenticate using shared helper.
$auth_token = get_option( 'wp_prometheus_auth_token', '' ); if ( ! wp_prometheus_authenticate_request() ) {
if ( empty( $auth_token ) ) {
status_header( 401 ); status_header( 401 );
header( 'WWW-Authenticate: Bearer realm="WP Prometheus Metrics"' ); header( 'WWW-Authenticate: Bearer realm="WP Prometheus Metrics"' );
header( 'Content-Type: text/plain; charset=utf-8' ); header( 'Content-Type: text/plain; charset=utf-8' );
@@ -72,41 +116,8 @@ function wp_prometheus_early_metrics_check(): void {
exit; exit;
} }
// Check Bearer token. // Set flag to indicate isolated mode - Collector will skip extensibility hooks.
$auth_header = ''; define( 'WP_PROMETHEUS_ISOLATED_MODE', true );
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 early metrics mode - Collector will skip extensibility hooks.
define( 'WP_PROMETHEUS_EARLY_METRICS', true );
// Remove all content filters to prevent recursion with Twig-based plugins.
remove_all_filters( 'the_content' );
remove_all_filters( 'the_excerpt' );
remove_all_filters( 'get_the_excerpt' );
remove_all_filters( 'the_title' );
// Output metrics and exit immediately. // Output metrics and exit immediately.
$collector = new \Magdev\WpPrometheus\Metrics\Collector(); $collector = new \Magdev\WpPrometheus\Metrics\Collector();
@@ -122,6 +133,64 @@ function wp_prometheus_early_metrics_check(): void {
exit; exit;
} }
/**
* Authenticate a metrics request using Bearer token or query parameter.
*
* Shared authentication logic used by both the MetricsEndpoint class
* and the isolated mode handler to avoid code duplication.
*
* @return bool True if authenticated, false otherwise.
*/
function wp_prometheus_authenticate_request(): bool {
$auth_token = get_option( 'wp_prometheus_auth_token', '' );
// If no token is set, deny access.
if ( empty( $auth_token ) ) {
return false;
}
// Check for Bearer token in Authorization header.
$auth_header = wp_prometheus_get_authorization_header();
if ( ! empty( $auth_header ) && preg_match( '/Bearer\s+(.*)$/i', $auth_header, $matches ) ) {
return hash_equals( $auth_token, $matches[1] );
}
// Check for token in query parameter (less secure but useful for testing).
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Auth token check.
if ( isset( $_GET['token'] ) && hash_equals( $auth_token, sanitize_text_field( wp_unslash( $_GET['token'] ) ) ) ) {
return true;
}
return false;
}
/**
* Get the Authorization header from the request.
*
* Checks multiple sources for the Authorization header to support
* different server configurations (Apache, nginx, CGI, etc.).
*
* @return string The Authorization header value, or empty string if not found.
*/
function wp_prometheus_get_authorization_header(): string {
if ( isset( $_SERVER['HTTP_AUTHORIZATION'] ) ) {
return sanitize_text_field( wp_unslash( $_SERVER['HTTP_AUTHORIZATION'] ) );
}
if ( isset( $_SERVER['REDIRECT_HTTP_AUTHORIZATION'] ) ) {
return sanitize_text_field( wp_unslash( $_SERVER['REDIRECT_HTTP_AUTHORIZATION'] ) );
}
if ( function_exists( 'apache_request_headers' ) ) {
$headers = apache_request_headers();
if ( isset( $headers['Authorization'] ) ) {
return sanitize_text_field( $headers['Authorization'] );
}
}
return '';
}
// Try early metrics handling before full plugin initialization. // Try early metrics handling before full plugin initialization.
wp_prometheus_early_metrics_check(); wp_prometheus_early_metrics_check();
@@ -130,7 +199,7 @@ wp_prometheus_early_metrics_check();
* *
* @var string * @var string
*/ */
define( 'WP_PROMETHEUS_VERSION', '0.4.3' ); define( 'WP_PROMETHEUS_VERSION', '0.5.0' );
/** /**
* Plugin file path. * Plugin file path.