15 Commits

Author SHA1 Message Date
52fd6da1d1 fix: Fix metric name sanitization, optimize transient queries, add domain binding (v0.5.1)
All checks were successful
Create Release Package / test (push) Successful in 43s
Create Release Package / build-release (push) Successful in 49s
- Add sanitize_metric_name() to preserve colons/uppercase in Prometheus names
- Combine 3 transient COUNT queries into single aggregated query
- Deduplicate inline HPOS check using existing is_hpos_enabled() method
- Add license domain binding for authorized deployment domains

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-02 21:45:36 +01:00
19d75ab7b2 feat: Add option to disable early mode (v0.4.2)
All checks were successful
Create Release Package / build-release (push) Successful in 56s
- Add wp_prometheus_disable_early_mode option in admin settings
- Support WP_PROMETHEUS_DISABLE_EARLY_MODE environment variable
- Add Early Mode section in Metrics tab with status indicator
- Allow users to enable wp_prometheus_collect_metrics hook

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-02 21:09:14 +01:00
fa63857f5f docs: Update CLAUDE.md with v0.4.1 session history
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-02 20:25:46 +01:00
34 changed files with 5201 additions and 389 deletions

View File

@@ -6,7 +6,32 @@ on:
- 'v*'
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:
needs: test
runs-on: ubuntu-latest
steps:
- name: Checkout code

3
.gitignore vendored
View File

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

View File

@@ -5,6 +5,155 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [0.5.1] - 2026-03-07
### Fixed
- Custom metric name sanitization: `sanitize_key()` was stripping colons and lowercasing names, silently mangling valid Prometheus metric names (e.g. `my:Custom_metric` became `mycustom_metric`). Added dedicated `sanitize_metric_name()` that preserves valid Prometheus characters.
### Changed
- Consolidated 3 separate transient COUNT queries into a single query with conditional aggregation for better database performance.
- Deduplicated inline HPOS check in WooCommerce customer metrics to use existing `is_hpos_enabled()` method.
- Added license domain binding for authorized deployment domains.
## [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
### Added
- Sub-tabs navigation within Metrics tab (Endpoint, Selection, Runtime, Advanced)
- Option to disable early mode in admin settings (Metrics → Advanced)
- Support for `WP_PROMETHEUS_DISABLE_EARLY_MODE` environment variable
- Early mode status display in settings
### Fixed
- Early mode setting now saves correctly (moved into form with proper settings group)
### Changed
- Reorganized Metrics tab into logical sub-sections for better usability
- Early mode can now be disabled for users who need the `wp_prometheus_collect_metrics` hook
- Updated translations with sub-tab and early mode strings (English and German)
## [0.4.1] - 2026-02-02
### Fixed

232
CLAUDE.md
View File

@@ -23,6 +23,7 @@ This plugin provides a Prometheus `/metrics` endpoint and an extensible way to a
- Grafana dashboard templates for easy visualization
- Dedicated plugin settings under 'Settings/Metrics' menu
- Extensible by other plugins using `wp_prometheus_collect_metrics` action hook
- Dashboard extension hook `wp_prometheus_register_dashboards` for third-party Grafana dashboards
- License management integration
### Key Fact: 100% AI-Generated
@@ -33,7 +34,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.
*No planned features at this time.*
### Known Bugs
*No known bugs at this time.*
## Technical Stack
@@ -78,11 +81,7 @@ Text domain: `wp-prometheus`
- `en_US` - English (United States) [base language - .pot template]
- `de_CH` - German (Switzerland, formal)
To compile translations to .mo files for production:
```bash
for po in languages/*.po; do msgfmt -o "${po%.po}.mo" "$po"; done
```
Translation compilation (.po → .mo) is handled automatically by CI/CD pipeline during release builds. No local compilation needed.
### Create releases
@@ -210,7 +209,7 @@ When editing CLAUDE.md or other markdown files, follow these rules to avoid lint
```txt
wp-prometheus/
├── .gitea/workflows/
│ └── release.yml # CI/CD pipeline
│ └── release.yml # CI/CD pipeline (test + build)
├── assets/
│ ├── css/ # Admin/Frontend styles
│ ├── dashboards/ # Grafana dashboard templates
@@ -239,10 +238,28 @@ wp-prometheus/
│ ├── Installer.php # Activation/Deactivation
│ ├── Plugin.php # Main plugin class
│ └── 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
├── CLAUDE.md
├── composer.json
├── index.php
├── phpunit.xml # PHPUnit 10 configuration
├── PLAN.md
├── README.md
├── uninstall.php
@@ -291,6 +308,207 @@ add_action( 'wp_prometheus_collect_metrics', function( $collector ) {
## 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)
- Split Metrics tab into sub-tabs for better organization:
- **Endpoint**: Authentication token configuration
- **Selection**: Enable/disable individual metrics
- **Runtime**: Reset runtime metrics data
- **Advanced**: Early mode toggle and status
- Fixed early mode setting not being saved (was outside form element)
- Added CSS styling for horizontal sub-tab navigation
- **Key Learning**: WordPress Settings API form structure
- Settings must be inside `<form action="options.php">` with `settings_fields()` call
- Each sub-tab needs its own form wrapper for proper saving
- Sub-tabs use URL query parameter (`subtab`) within the main tab
- **Key Learning**: WordPress plugin versioning requires TWO updates
- Plugin header comment `Version: x.x.x` (line ~6) - used by WordPress admin
- PHP constant `WP_PROMETHEUS_VERSION` (line ~133) - used internally
- CI/CD checks both must match the git tag, causing release failures if mismatched
### 2026-02-02 - Early Mode Toggle (v0.4.2)
- Added option to disable early mode for users who need extensibility
- Implementation:
- Added `wp_prometheus_disable_early_mode` WordPress option
- Added `WP_PROMETHEUS_DISABLE_EARLY_MODE` environment variable support
- Option check in `wp_prometheus_early_metrics_check()` before early interception
- Environment variable accepts `1`, `true`, `yes`, `on` (case-insensitive)
- Admin UI in Metrics tab:
- "Early Mode" section with description of functionality
- Checkbox to disable early metrics interception
- Environment override notice when env var is set
- Current status indicator showing early mode state
- **Key Learning**: Balancing compatibility vs extensibility
- Early mode fixes memory issues but disables `wp_prometheus_collect_metrics` hook
- Users with custom metrics need the hook, so early mode must be optional
- Default remains enabled (safe) with explicit opt-out for advanced users
### 2026-02-02 - Plugin Compatibility Fix (v0.4.1)
- Fixed memory exhaustion (1GB limit) when wp-fedistream (Twig-based) plugin is active
- Root cause: Infinite recursion through WordPress hook system when content filters trigger Twig rendering
- Solution: Early metrics endpoint interception before full WordPress initialization
- Implementation changes:
- Added `wp_prometheus_early_metrics_check()` in bootstrap file (wp-prometheus.php)
- Checks REQUEST_URI for `/metrics` pattern before `plugins_loaded` fires
- Defines `WP_PROMETHEUS_EARLY_METRICS` constant to signal early mode
- Removes content filters (`the_content`, `the_excerpt`, `get_the_excerpt`, `the_title`)
- Collector skips `wp_prometheus_collect_metrics` action in early mode
- Changed MetricsEndpoint from `template_redirect` to `parse_request` hook
- **Key Learning**: WordPress plugin loading order and hook timing
- Plugins load alphabetically, so wp-fedistream ('f') loads before wp-prometheus ('p')
- `template_redirect` fires too late - after themes and Twig initialize
- `parse_request` fires earlier but still after plugin files load
- Earliest interception point: top-level code in plugin bootstrap file
- **Key Learning**: Content filter recursion in WordPress
- `get_the_excerpt()` internally triggers `apply_filters('the_content', ...)`
- This creates unexpected recursion vectors when Twig templates process content
- Solution: Remove all content-related filters before metrics collection
- **Key Learning**: Isolating metrics collection from WordPress template system
- Use `remove_all_filters()` to clear problematic filter chains
- Skip extensibility hooks (`do_action`) when in isolated early mode
- Exit immediately after output to prevent further WordPress processing
### 2026-02-02 - Persistent Storage (v0.4.0)
- Added persistent storage support for metrics:

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

View File

@@ -9,6 +9,61 @@
margin-top: 20px;
}
/* Sub-tabs navigation */
.wp-prometheus-subtabs {
margin-top: 15px;
}
.wp-prometheus-subtab-nav {
display: flex;
margin: 0;
padding: 0;
list-style: none;
border-bottom: 1px solid #c3c4c7;
}
.wp-prometheus-subtab-item {
margin: 0;
padding: 0;
}
.wp-prometheus-subtab-item a {
display: block;
padding: 8px 16px;
text-decoration: none;
color: #50575e;
border: 1px solid transparent;
border-bottom: none;
margin-bottom: -1px;
background: transparent;
font-size: 13px;
font-weight: 400;
}
.wp-prometheus-subtab-item a:hover {
color: #2271b1;
background: #f6f7f7;
}
.wp-prometheus-subtab-item.active a {
color: #1d2327;
background: #fff;
border-color: #c3c4c7;
border-bottom-color: #fff;
font-weight: 600;
}
.wp-prometheus-subtab-content {
background: #fff;
border: 1px solid #c3c4c7;
border-top: none;
padding: 20px;
}
.wp-prometheus-subtab-content h3:first-child {
margin-top: 0;
}
/* License status box */
.wp-prometheus-license-status {
margin: 15px 0;
@@ -161,6 +216,32 @@
font-size: 13px;
}
/* Third-party dashboard card styling */
.wp-prometheus-dashboard-card.third-party {
position: relative;
border-color: #2271b1;
}
.wp-prometheus-dashboard-card .dashboard-badge {
position: absolute;
top: -8px;
right: -8px;
background: #2271b1;
color: #fff;
font-size: 10px;
font-weight: 600;
padding: 3px 8px;
border-radius: 10px;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.wp-prometheus-dashboard-card .dashboard-plugin {
color: #646970;
margin: -5px 0 15px 0;
font-style: italic;
}
/* Import options panel */
#import-options {
border-radius: 4px;

View File

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

View File

@@ -235,31 +235,19 @@
$spinner.removeClass('is-active');
if (response.success) {
$message
.removeClass('notice-error')
.addClass('notice notice-success')
.html('<p>' + response.data.message + '</p>')
.show();
showNotice($message, response.data.message, 'success');
// Reload page after successful validation/activation.
setTimeout(function() {
location.reload();
}, 1500);
} else {
$message
.removeClass('notice-success')
.addClass('notice notice-error')
.html('<p>' + (response.data.message || 'An error occurred.') + '</p>')
.show();
showNotice($message, response.data.message || 'An error occurred.', 'error');
}
},
error: function() {
$spinner.removeClass('is-active');
$message
.removeClass('notice-success')
.addClass('notice notice-error')
.html('<p>Connection error. Please try again.</p>')
.show();
showNotice($message, 'Connection error. Please try again.', 'error');
}
});
}
@@ -287,30 +275,18 @@
$spinner.removeClass('is-active');
if (response.success) {
$message
.removeClass('notice-error')
.addClass('notice notice-success')
.html('<p>' + response.data.message + '</p>')
.show();
showNotice($message, response.data.message, 'success');
setTimeout(function() {
window.location.href = window.location.pathname + '?page=wp-prometheus&tab=custom';
}, 1000);
} else {
$message
.removeClass('notice-success')
.addClass('notice notice-error')
.html('<p>' + (response.data.message || 'An error occurred.') + '</p>')
.show();
showNotice($message, response.data.message || 'An error occurred.', 'error');
}
},
error: function() {
$spinner.removeClass('is-active');
$message
.removeClass('notice-success')
.addClass('notice notice-error')
.html('<p>Connection error. Please try again.</p>')
.show();
showNotice($message, 'Connection error. Please try again.', 'error');
}
});
}
@@ -398,11 +374,7 @@
$spinner.removeClass('is-active');
if (response.success) {
$message
.removeClass('notice-error')
.addClass('notice notice-success')
.html('<p>' + response.data.message + '</p>')
.show();
showNotice($message, response.data.message, 'success');
$('#import-options').slideUp();
$('#import-metrics-file').val('');
@@ -412,20 +384,12 @@
location.reload();
}, 1500);
} else {
$message
.removeClass('notice-success')
.addClass('notice notice-error')
.html('<p>' + (response.data.message || 'An error occurred.') + '</p>')
.show();
showNotice($message, response.data.message || 'An error occurred.', 'error');
}
},
error: function() {
$spinner.removeClass('is-active');
$message
.removeClass('notice-success')
.addClass('notice notice-error')
.html('<p>Connection error. Please try again.</p>')
.show();
showNotice($message, 'Connection error. Please try again.', 'error');
}
});
}
@@ -478,26 +442,14 @@
$spinner.removeClass('is-active');
if (response.success) {
$message
.removeClass('notice-error')
.addClass('notice notice-success')
.html('<p>' + response.data.message + '</p>')
.show();
showNotice($message, response.data.message, 'success');
} else {
$message
.removeClass('notice-success')
.addClass('notice notice-error')
.html('<p>' + (response.data.message || 'An error occurred.') + '</p>')
.show();
showNotice($message, response.data.message || 'An error occurred.', 'error');
}
},
error: function() {
$spinner.removeClass('is-active');
$message
.removeClass('notice-success')
.addClass('notice notice-error')
.html('<p>Connection error. Please try again.</p>')
.show();
showNotice($message, 'Connection error. Please try again.', 'error');
}
});
}
@@ -546,15 +498,30 @@
// Remove all inputs except the value and button.
$row.find('input').remove();
// Re-add label inputs.
// Re-add label inputs using safe DOM construction.
for (var i = 0; i < labelCount; 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] || '';
$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.
* @return {string} Generated token.
@@ -592,12 +559,31 @@
function generateToken(length) {
var charset = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
var token = '';
var randomValues = new Uint32Array(length);
window.crypto.getRandomValues(randomValues);
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;
}
/**
* 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.
*
@@ -666,12 +652,8 @@
$spinner.removeClass('is-active');
if (response.success) {
var noticeClass = response.data.warning ? 'notice-warning' : 'notice-success';
$message
.removeClass('notice-error notice-success notice-warning')
.addClass('notice ' + noticeClass)
.html('<p>' + response.data.message + '</p>')
.show();
var type = response.data.warning ? 'warning' : 'success';
showNotice($message, response.data.message, type);
if (!response.data.warning) {
setTimeout(function() {
@@ -679,20 +661,12 @@
}, 1500);
}
} else {
$message
.removeClass('notice-success notice-warning')
.addClass('notice notice-error')
.html('<p>' + (response.data.message || 'An error occurred.') + '</p>')
.show();
showNotice($message, response.data.message || 'An error occurred.', 'error');
}
},
error: function() {
$spinner.removeClass('is-active');
$message
.removeClass('notice-success notice-warning')
.addClass('notice notice-error')
.html('<p>Connection error. Please try again.</p>')
.show();
showNotice($message, 'Connection error. Please try again.', 'error');
}
});
}
@@ -720,26 +694,14 @@
$spinner.removeClass('is-active');
if (response.success) {
$message
.removeClass('notice-error notice-warning')
.addClass('notice notice-success')
.html('<p>' + response.data.message + '</p>')
.show();
showNotice($message, response.data.message, 'success');
} else {
$message
.removeClass('notice-success notice-warning')
.addClass('notice notice-error')
.html('<p>' + (response.data.message || 'Connection test failed.') + '</p>')
.show();
showNotice($message, response.data.message || 'Connection test failed.', 'error');
}
},
error: function() {
$spinner.removeClass('is-active');
$message
.removeClass('notice-success notice-warning')
.addClass('notice notice-error')
.html('<p>Connection error. Please try again.</p>')
.show();
showNotice($message, 'Connection error. Please try again.', 'error');
}
});
}

View File

@@ -32,6 +32,7 @@
"promphp/prometheus_client_php": "^2.10"
},
"require-dev": {
"php-mock/php-mock-phpunit": "^2.10",
"phpunit/phpunit": "^10.0",
"squizlabs/php_codesniffer": "^3.7",
"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",
"This file is @generated automatically"
],
"content-hash": "3cacd9c609c3c49fb4600d09a38c04be",
"content-hash": "cfd3853b3cf76d82f972f3326e4f94d3",
"packages": [
{
"name": "magdev/wc-licensed-product-client",
@@ -1128,6 +1128,213 @@
},
"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",
"version": "9.3.5",

Binary file not shown.

View File

@@ -3,7 +3,7 @@
# This file is distributed under the GPL v2 or later.
msgid ""
msgstr ""
"Project-Id-Version: WP Prometheus 0.4.0\n"
"Project-Id-Version: WP Prometheus 0.4.2\n"
"Report-Msgid-Bugs-To: https://src.bundespruefstelle.ch/magdev/wp-prometheus/issues\n"
"POT-Creation-Date: 2026-02-02T00:00:00+00:00\n"
"PO-Revision-Date: 2026-02-02T00:00:00+00:00\n"
@@ -45,11 +45,11 @@ msgstr "Lizenz-Einstellungen gespeichert."
#: src/Admin/Settings.php
msgid "License is active and valid."
msgstr "Lizenz ist aktiv und gueltig."
msgstr "Lizenz ist aktiv und gültig."
#: src/Admin/Settings.php
msgid "License is invalid."
msgstr "Lizenz ist ungueltig."
msgstr "Lizenz ist ungültig."
#: src/Admin/Settings.php
msgid "License has expired."
@@ -78,12 +78,12 @@ msgstr "Unbekannter Status."
#. translators: %s: Expiration date
#: src/Admin/Settings.php
msgid "Expires: %s"
msgstr "Laeuft ab: %s"
msgstr "Läuft ab: %s"
#. translators: %s: Time ago
#: src/Admin/Settings.php
msgid "Last checked: %s ago"
msgstr "Zuletzt geprueft: vor %s"
msgstr "Zuletzt geprüft: vor %s"
#: src/Admin/Settings.php
msgid "License Server URL"
@@ -91,7 +91,7 @@ msgstr "Lizenz-Server URL"
#: src/Admin/Settings.php
msgid "License Key"
msgstr "Lizenzschluessel"
msgstr "Lizenzschlüssel"
#: src/Admin/Settings.php
msgid "Server Secret"
@@ -101,6 +101,18 @@ msgstr "Server-Geheimnis"
msgid "Leave empty to keep existing."
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
msgid "Save License Settings"
msgstr "Lizenz-Einstellungen speichern"
@@ -123,11 +135,11 @@ msgstr "Aktivierte Metriken"
#: src/Admin/Settings.php
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
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
msgid "Auth Token"
@@ -135,7 +147,7 @@ msgstr "Auth-Token"
#: src/Admin/Settings.php
msgid "Select Metrics"
msgstr "Metriken auswaehlen"
msgstr "Metriken auswählen"
#: src/Admin/Settings.php
msgid "Regenerate"
@@ -159,7 +171,7 @@ msgstr "Benutzer nach Rolle"
#: src/Admin/Settings.php
msgid "Total Posts by Type and Status"
msgstr "Beitraege nach Typ und Status"
msgstr "Beiträge nach Typ und Status"
#: src/Admin/Settings.php
msgid "Total Comments by Status"
@@ -171,7 +183,7 @@ msgstr "Plugins (aktiv/inaktiv)"
#: src/Admin/Settings.php
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
msgid "Transients (total, expiring, expired)"
@@ -183,7 +195,7 @@ msgstr "Laufzeit-Metriken"
#: src/Admin/Settings.php
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
msgid "HTTP Requests Total (by method, status, endpoint)"
@@ -203,7 +215,7 @@ msgstr "WooCommerce-Metriken"
#: src/Admin/Settings.php
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
msgid "WooCommerce Products (by status and type)"
@@ -223,15 +235,15 @@ msgstr "WooCommerce-Kunden (registriert, Gast)"
#: src/Admin/Settings.php
msgid "Reset Runtime Metrics"
msgstr "Laufzeit-Metriken zuruecksetzen"
msgstr "Laufzeit-Metriken zurücksetzen"
#: src/Admin/Settings.php
msgid "Clear all accumulated runtime metric data."
msgstr "Alle gesammelten Laufzeit-Metrikdaten loeschen."
msgstr "Alle gesammelten Laufzeit-Metrikdaten löschen."
#: src/Admin/Settings.php
msgid "Reset Metrics"
msgstr "Metriken zuruecksetzen"
msgstr "Metriken zurücksetzen"
#: src/Admin/Settings.php
msgid "Prometheus Configuration"
@@ -239,7 +251,7 @@ msgstr "Prometheus-Konfiguration"
#: src/Admin/Settings.php
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
msgid "Endpoint Information"
@@ -255,11 +267,11 @@ msgstr "Endpunkt testen"
#: src/Admin/Settings.php
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
msgid "Available Metrics"
msgstr "Verfuegbare Metriken"
msgstr "Verfügbare Metriken"
#: src/Admin/Settings.php
msgid "Metric"
@@ -295,7 +307,7 @@ msgstr "Benutzer gesamt nach Rolle"
#: src/Admin/Settings.php
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
msgid "Total comments by status"
@@ -317,17 +329,21 @@ msgstr "HTTP-Anfragedauer-Verteilung"
msgid "Database queries by endpoint"
msgstr "Datenbank-Abfragen nach Endpunkt"
#: src/Admin/Settings.php
msgid "Database query duration distribution (requires SAVEQUERIES)"
msgstr "Datenbank-Abfragedauer-Verteilung (erfordert SAVEQUERIES)"
#: src/Admin/Settings.php
msgid "Scheduled cron events by hook"
msgstr "Geplante Cron-Ereignisse nach Hook"
#: src/Admin/Settings.php
msgid "Number of overdue cron events"
msgstr "Anzahl ueberfaelliger Cron-Ereignisse"
msgstr "Anzahl überfälliger Cron-Ereignisse"
#: src/Admin/Settings.php
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
msgid "Total transients by type"
@@ -351,11 +367,11 @@ msgstr "WooCommerce-Kunden nach Typ"
#: src/Admin/Settings.php
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
msgid "Add Custom Metric"
msgstr "Eigene Metrik hinzufuegen"
msgstr "Eigene Metrik hinzufügen"
#: src/Admin/Settings.php
msgid "Edit Custom Metric"
@@ -411,7 +427,7 @@ msgstr "Label-Name"
#: src/Admin/Settings.php
msgid "Add Label"
msgstr "Label hinzufuegen"
msgstr "Label hinzufügen"
#: src/Admin/Settings.php
msgid "Label Values"
@@ -423,7 +439,7 @@ msgstr "Wert"
#: src/Admin/Settings.php
msgid "Add Value Row"
msgstr "Wertezeile hinzufuegen"
msgstr "Wertezeile hinzufügen"
#: src/Admin/Settings.php
msgid "Enabled"
@@ -467,7 +483,7 @@ msgstr "Bearbeiten"
#: src/Admin/Settings.php
msgid "Delete"
msgstr "Loeschen"
msgstr "Löschen"
#: src/Admin/Settings.php
msgid "No custom metrics defined yet."
@@ -479,7 +495,7 @@ msgstr "Export / Import"
#: src/Admin/Settings.php
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
msgid "Export Metrics"
@@ -495,11 +511,11 @@ msgstr "Import-Optionen"
#: src/Admin/Settings.php
msgid "Skip existing metrics"
msgstr "Bestehende Metriken ueberspringen"
msgstr "Bestehende Metriken überspringen"
#: src/Admin/Settings.php
msgid "Overwrite existing metrics"
msgstr "Bestehende Metriken ueberschreiben"
msgstr "Bestehende Metriken überschreiben"
#: src/Admin/Settings.php
msgid "Rename duplicates"
@@ -527,7 +543,7 @@ msgstr "Import-Anleitung:"
#: src/Admin/Settings.php
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
msgid "In Grafana, go to Dashboards > Import"
@@ -535,11 +551,11 @@ msgstr "Gehen Sie in Grafana zu Dashboards > Import"
#: src/Admin/Settings.php
msgid "Upload the JSON file or paste its contents"
msgstr "Laden Sie die JSON-Datei hoch oder fuegen Sie deren Inhalt ein"
msgstr "Laden Sie die JSON-Datei hoch oder fügen Sie deren Inhalt ein"
#: src/Admin/Settings.php
msgid "Select your Prometheus data source"
msgstr "Waehlen Sie Ihre Prometheus-Datenquelle"
msgstr "Wählen Sie Ihre Prometheus-Datenquelle"
#: src/Admin/Settings.php
msgid "Click Import"
@@ -551,7 +567,7 @@ msgstr "Metrik-Name ist erforderlich."
#: src/Metrics/CustomMetricBuilder.php
msgid "Invalid metric name format."
msgstr "Ungueltiges Metrik-Namensformat."
msgstr "Ungültiges Metrik-Namensformat."
#: src/Metrics/CustomMetricBuilder.php
msgid "A metric with this name already exists."
@@ -563,7 +579,7 @@ msgstr "Hilfetext ist erforderlich."
#: src/Metrics/CustomMetricBuilder.php
msgid "Invalid value type."
msgstr "Ungueltiger Werttyp."
msgstr "Ungültiger Werttyp."
#: src/Metrics/CustomMetricBuilder.php
msgid "Static value must be numeric."
@@ -571,7 +587,7 @@ msgstr "Statischer Wert muss numerisch sein."
#: src/Metrics/CustomMetricBuilder.php
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
#: src/Metrics/CustomMetricBuilder.php
@@ -580,7 +596,7 @@ msgstr "Maximal %d Labels erlaubt."
#: src/Metrics/CustomMetricBuilder.php
msgid "Invalid label name format."
msgstr "Ungueltiges Label-Namensformat."
msgstr "Ungültiges Label-Namensformat."
#. translators: %d: Maximum number of label value combinations
#: src/Metrics/CustomMetricBuilder.php
@@ -589,11 +605,11 @@ msgstr "Maximal %d Label-Wert-Kombinationen erlaubt."
#: src/Metrics/CustomMetricBuilder.php
msgid "Invalid JSON format."
msgstr "Ungueltiges JSON-Format."
msgstr "Ungültiges JSON-Format."
#: src/Metrics/CustomMetricBuilder.php
msgid "Invalid export format."
msgstr "Ungueltiges Export-Format."
msgstr "Ungültiges Export-Format."
#: src/Plugin.php
msgid "Settings"
@@ -602,21 +618,21 @@ msgstr "Einstellungen"
#. translators: 1: Required PHP version, 2: Current PHP version
#: wp-prometheus.php
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
#: wp-prometheus.php
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
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
#: wp-prometheus.php
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
msgid "Storage"
@@ -628,15 +644,15 @@ msgstr "Metriken-Speicherkonfiguration"
#: src/Admin/Settings.php
msgid "Configure how Prometheus metrics are stored. Persistent storage (Redis, APCu) allows metrics to survive between requests and aggregate data over time."
msgstr "Konfigurieren Sie, wie Prometheus-Metriken gespeichert werden. Persistenter Speicher (Redis, APCu) ermoeglicht es, Metriken zwischen Anfragen zu erhalten und Daten ueber Zeit zu aggregieren."
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
msgid "Environment Override Active"
msgstr "Umgebungsvariablen-Ueberschreibung aktiv"
msgstr "Umgebungsvariablen-Überschreibung aktiv"
#: src/Admin/Settings.php
msgid "Storage adapter is configured via environment variable. Admin settings will be ignored."
msgstr "Speicher-Adapter ist ueber Umgebungsvariable konfiguriert. Admin-Einstellungen werden ignoriert."
msgstr "Speicher-Adapter ist über Umgebungsvariable konfiguriert. Admin-Einstellungen werden ignoriert."
#: src/Admin/Settings.php
msgid "Storage Fallback Active"
@@ -644,7 +660,7 @@ msgstr "Speicher-Fallback aktiv"
#: src/Admin/Settings.php
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
msgid "Current Status:"
@@ -661,11 +677,11 @@ msgstr "Speicher-Adapter"
#: src/Admin/Settings.php
msgid "unavailable"
msgstr "nicht verfuegbar"
msgstr "nicht verfügbar"
#: src/Admin/Settings.php
msgid "Select the storage backend for metrics. Redis and APCu require their respective PHP extensions."
msgstr "Waehlen Sie das Speicher-Backend fuer Metriken. Redis und APCu erfordern ihre jeweiligen PHP-Erweiterungen."
msgstr "Wählen Sie das Speicher-Backend für Metriken. Redis und APCu erfordern ihre jeweiligen PHP-Erweiterungen."
#: src/Admin/Settings.php
msgid "Redis Configuration"
@@ -678,7 +694,7 @@ msgstr "Host"
#. translators: %s: Environment variable name
#: src/Admin/Settings.php
msgid "Can be overridden with %s environment variable."
msgstr "Kann mit Umgebungsvariable %s ueberschrieben werden."
msgstr "Kann mit Umgebungsvariable %s überschrieben werden."
#: src/Admin/Settings.php
msgid "Port"
@@ -699,15 +715,15 @@ msgstr "Datenbank"
#. translators: %s: Environment variable name
#: src/Admin/Settings.php
msgid "Redis database index (0-15). Can be overridden with %s."
msgstr "Redis-Datenbankindex (0-15). Kann mit %s ueberschrieben werden."
msgstr "Redis-Datenbankindex (0-15). Kann mit %s überschrieben werden."
#: src/Admin/Settings.php
msgid "Key Prefix"
msgstr "Schluessel-Praefix"
msgstr "Schlüssel-Präfix"
#: src/Admin/Settings.php
msgid "Prefix for Redis keys. Useful when sharing Redis with other applications."
msgstr "Praefix fuer Redis-Schluessel. Nuetzlich bei gemeinsamer Redis-Nutzung mit anderen Anwendungen."
msgstr "Präfix für Redis-Schlüssel. Nützlich bei gemeinsamer Redis-Nutzung mit anderen Anwendungen."
#: src/Admin/Settings.php
msgid "APCu Configuration"
@@ -716,7 +732,7 @@ msgstr "APCu-Konfiguration"
#. translators: %s: Environment variable name
#: src/Admin/Settings.php
msgid "Prefix for APCu keys. Can be overridden with %s."
msgstr "Praefix fuer APCu-Schluessel. Kann mit %s ueberschrieben werden."
msgstr "Präfix für APCu-Schlüssel. Kann mit %s überschrieben werden."
#: src/Admin/Settings.php
msgid "Save Storage Settings"
@@ -732,7 +748,7 @@ msgstr "Umgebungsvariablen"
#: src/Admin/Settings.php
msgid "For Docker or containerized environments, you can configure storage using environment variables. These take precedence over admin settings."
msgstr "Fuer Docker- oder Container-Umgebungen koennen Sie den Speicher ueber Umgebungsvariablen konfigurieren. Diese haben Vorrang vor Admin-Einstellungen."
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
msgid "Variable"
@@ -764,11 +780,23 @@ msgstr "Redis-Datenbankindex"
#: src/Admin/Settings.php
msgid "Redis key prefix"
msgstr "Redis-Schluessel-Praefix"
msgstr "Redis-Schlüssel-Präfix"
#: src/Admin/Settings.php
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
msgid "Docker Compose Example"
@@ -780,11 +808,11 @@ msgstr "Zugriff verweigert."
#: src/Admin/Settings.php
msgid "Storage adapter is configured via environment variable and cannot be changed."
msgstr "Speicher-Adapter ist ueber Umgebungsvariable konfiguriert und kann nicht geaendert werden."
msgstr "Speicher-Adapter ist über Umgebungsvariable konfiguriert und kann nicht geändert werden."
#: src/Admin/Settings.php
msgid "Invalid storage adapter."
msgstr "Ungueltiger Speicher-Adapter."
msgstr "Ungültiger Speicher-Adapter."
#: src/Admin/Settings.php
msgid "Storage settings saved successfully."
@@ -840,7 +868,7 @@ msgstr "APCu-Fehler: %s"
#: src/Metrics/StorageFactory.php
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
msgid "Unknown storage adapter."
@@ -865,7 +893,7 @@ msgstr "Redis-Ping fehlgeschlagen."
#: src/Metrics/StorageFactory.php
msgid "APCu is installed but not enabled. Check your php.ini settings."
msgstr "APCu ist installiert, aber nicht aktiviert. Pruefen Sie Ihre php.ini-Einstellungen."
msgstr "APCu ist installiert, aber nicht aktiviert. Prüfen Sie Ihre php.ini-Einstellungen."
#: src/Metrics/StorageFactory.php
msgid "APCu store operation failed."
@@ -878,4 +906,118 @@ msgstr "APCu funktioniert. Speicher: %s belegt."
#: src/Metrics/StorageFactory.php
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
msgid "Early Mode"
msgstr "Frühzeitiger Modus"
#: src/Admin/Settings.php
msgid "Early mode intercepts /metrics requests before full WordPress initialization. This prevents memory exhaustion issues caused by some plugins (e.g., Twig-based themes/plugins) but disables the wp_prometheus_collect_metrics hook for custom metrics."
msgstr "Der 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
msgid "Early mode is configured via WP_PROMETHEUS_DISABLE_EARLY_MODE environment variable. Admin settings will be ignored."
msgstr "Der frühzeitige Modus ist über die Umgebungsvariable WP_PROMETHEUS_DISABLE_EARLY_MODE konfiguriert. Admin-Einstellungen werden ignoriert."
#: src/Admin/Settings.php
msgid "Disable Early Mode"
msgstr "Frühzeitigen Modus deaktivieren"
#: src/Admin/Settings.php
msgid "Disable early metrics interception"
msgstr "Frühzeitige Metriken-Abfangung deaktivieren"
#: src/Admin/Settings.php
msgid "When disabled, metrics are collected through normal WordPress template loading. This enables the wp_prometheus_collect_metrics hook for custom metrics but may cause issues with some plugins."
msgstr "Wenn deaktiviert, werden Metriken ü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
msgid "Early mode is active (this request was served via early interception)"
msgstr "Frühzeitiger Modus ist aktiv (diese Anfrage wurde über frühzeitige Abfangung verarbeitet)"
#: src/Admin/Settings.php
msgid "Early mode is disabled"
msgstr "Frühzeitiger Modus ist deaktiviert"
#: src/Admin/Settings.php
msgid "Early mode is enabled (active for /metrics requests)"
msgstr "Frühzeitiger Modus ist aktiviert (aktiv für /metrics-Anfragen)"
#: src/Admin/Settings.php
msgid "Clear all accumulated runtime metric data (HTTP requests, database queries). This is useful for testing or starting fresh."
msgstr "Alle gesammelten Laufzeit-Metrikdaten löschen (HTTP-Anfragen, Datenbank-Abfragen). Dies ist nützlich zum Testen oder für einen Neuanfang."
#: src/Admin/Settings.php
msgid "Endpoint"
msgstr "Endpunkt"
#: src/Admin/Settings.php
msgid "Selection"
msgstr "Auswahl"
#: src/Admin/Settings.php
msgid "Runtime"
msgstr "Laufzeit"
#: src/Admin/Settings.php
msgid "Advanced"
msgstr "Erweitert"
#: src/Admin/Settings.php
msgid "Runtime Metrics Management"
msgstr "Laufzeit-Metriken Verwaltung"
#: src/Admin/Settings.php
msgid "Runtime metrics track HTTP requests and database queries across requests. Use this section to manage accumulated data."
msgstr "Laufzeit-Metriken erfassen HTTP-Anfragen und Datenbank-Abfragen über mehrere Anfragen hinweg. Verwenden Sie diesen Bereich zur Verwaltung der gesammelten Daten."
#: src/Admin/Settings.php
msgid "Reset Data"
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

@@ -2,7 +2,7 @@
# This file is distributed under the GPL v2 or later.
msgid ""
msgstr ""
"Project-Id-Version: WP Prometheus 0.4.0\n"
"Project-Id-Version: WP Prometheus 0.4.2\n"
"Report-Msgid-Bugs-To: https://src.bundespruefstelle.ch/magdev/wp-prometheus/issues\n"
"POT-Creation-Date: 2026-02-02T00:00:00+00:00\n"
"MIME-Version: 1.0\n"
@@ -98,6 +98,18 @@ msgstr ""
msgid "Leave empty to keep existing."
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
msgid "Save License Settings"
msgstr ""
@@ -314,6 +326,10 @@ msgstr ""
msgid "Database queries by endpoint"
msgstr ""
#: src/Admin/Settings.php
msgid "Database query duration distribution (requires SAVEQUERIES)"
msgstr ""
#: src/Admin/Settings.php
msgid "Scheduled cron events by hook"
msgstr ""
@@ -767,6 +783,18 @@ msgstr ""
msgid "APCu key prefix"
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
msgid "Docker Compose Example"
msgstr ""
@@ -876,3 +904,117 @@ msgstr ""
#: src/Metrics/StorageFactory.php
msgid "APCu fetch operation returned unexpected value."
msgstr ""
#: src/Admin/Settings.php
msgid "Early Mode"
msgstr ""
#: src/Admin/Settings.php
msgid "Early mode intercepts /metrics requests before full WordPress initialization. This prevents memory exhaustion issues caused by some plugins (e.g., Twig-based themes/plugins) but disables the wp_prometheus_collect_metrics hook for custom metrics."
msgstr ""
#: src/Admin/Settings.php
msgid "Early mode is configured via WP_PROMETHEUS_DISABLE_EARLY_MODE environment variable. Admin settings will be ignored."
msgstr ""
#: src/Admin/Settings.php
msgid "Disable Early Mode"
msgstr ""
#: src/Admin/Settings.php
msgid "Disable early metrics interception"
msgstr ""
#: src/Admin/Settings.php
msgid "When disabled, metrics are collected through normal WordPress template loading. This enables the wp_prometheus_collect_metrics hook for custom metrics but may cause issues with some plugins."
msgstr ""
#: src/Admin/Settings.php
msgid "Early mode is active (this request was served via early interception)"
msgstr ""
#: src/Admin/Settings.php
msgid "Early mode is disabled"
msgstr ""
#: src/Admin/Settings.php
msgid "Early mode is enabled (active for /metrics requests)"
msgstr ""
#: src/Admin/Settings.php
msgid "Clear all accumulated runtime metric data (HTTP requests, database queries). This is useful for testing or starting fresh."
msgstr ""
#: src/Admin/Settings.php
msgid "Endpoint"
msgstr ""
#: src/Admin/Settings.php
msgid "Selection"
msgstr ""
#: src/Admin/Settings.php
msgid "Runtime"
msgstr ""
#: src/Admin/Settings.php
msgid "Advanced"
msgstr ""
#: src/Admin/Settings.php
msgid "Runtime Metrics Management"
msgstr ""
#: src/Admin/Settings.php
msgid "Runtime metrics track HTTP requests and database queries across requests. Use this section to manage accumulated data."
msgstr ""
#: src/Admin/Settings.php
msgid "Reset Data"
msgstr ""
#: src/Admin/Settings.php
msgid "Extension"
msgstr ""
#. translators: %s: Plugin name
#: src/Admin/Settings.php
msgid "Provided by: %s"
msgstr ""
#: src/Admin/Settings.php
msgid "No dashboards available."
msgstr ""
#: src/Admin/Settings.php
msgid "Pre-built dashboards for visualizing your WordPress metrics in Grafana."
msgstr ""
#: src/Admin/Settings.php
msgid "Installation Instructions"
msgstr ""
#: src/Admin/Settings.php
msgid "Download the JSON file for your desired dashboard."
msgstr ""
#: src/Admin/Settings.php
msgid "In Grafana, go to Dashboards → Import."
msgstr ""
#: src/Admin/Settings.php
msgid "Upload the JSON file or paste its contents."
msgstr ""
#: src/Admin/Settings.php
msgid "Select your Prometheus data source when prompted."
msgstr ""
#: src/Admin/Settings.php
msgid "Click Import to create the dashboard."
msgstr ""
#. translators: %s: Metrics URL
#: src/Admin/Settings.php
msgid "Make sure your Prometheus instance is configured to scrape %s with the correct authentication token."
msgstr ""

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.
*
* Provides Grafana dashboard templates for download.
* Supports both built-in dashboards and third-party registrations.
*/
class DashboardProvider {
/**
* Dashboard directory path.
* Dashboard directory path for built-in dashboards.
*
* @var string
*/
private string $dashboard_dir;
/**
* Available dashboard definitions.
* Built-in dashboard definitions.
*
* @var array
*/
private array $dashboards = array();
private array $builtin_dashboards = array();
/**
* Third-party registered dashboard definitions.
*
* @var array
*/
private array $registered_dashboards = array();
/**
* Whether the registration hook has been fired.
*
* @var bool
*/
private bool $hook_fired = false;
/**
* Constructor.
*/
public function __construct() {
$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(
'title' => __( 'WordPress Overview', 'wp-prometheus' ),
'description' => __( 'General WordPress metrics including users, posts, comments, and plugins.', 'wp-prometheus' ),
'file' => 'wordpress-overview.json',
'icon' => 'dashicons-wordpress',
'source' => 'builtin',
),
'wordpress-runtime' => array(
'title' => __( 'Runtime Performance', 'wp-prometheus' ),
'description' => __( 'HTTP request metrics, database query performance, and response times.', 'wp-prometheus' ),
'file' => 'wordpress-runtime.json',
'icon' => 'dashicons-performance',
'source' => 'builtin',
),
'wordpress-woocommerce' => array(
'title' => __( 'WooCommerce Store', 'wp-prometheus' ),
'description' => __( 'WooCommerce metrics including products, orders, revenue, and customers.', 'wp-prometheus' ),
'file' => 'wordpress-woocommerce.json',
'icon' => 'dashicons-cart',
'source' => 'builtin',
),
);
}
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.
@@ -67,15 +280,28 @@ class DashboardProvider {
* @return array
*/
public function get_available(): array {
// Fire registration hook first (only once).
$this->fire_registration_hook();
$available = array();
foreach ( $this->dashboards as $slug => $dashboard ) {
// Add built-in dashboards (check file exists).
foreach ( $this->get_builtin_dashboards() as $slug => $dashboard ) {
$file_path = $this->dashboard_dir . $dashboard['file'];
if ( file_exists( $file_path ) ) {
$available[ $slug ] = $dashboard;
}
}
// Add registered third-party dashboards.
foreach ( $this->registered_dashboards as $slug => $dashboard ) {
// Already validated during registration, but double-check.
if ( ! empty( $dashboard['json'] ) ||
( ! empty( $dashboard['file'] ) && file_exists( $dashboard['file'] ) ) ) {
$available[ $slug ] = $dashboard;
}
}
return $available;
}
@@ -86,20 +312,24 @@ class DashboardProvider {
* @return string|null JSON content or null if not found.
*/
public function get_dashboard( string $slug ): ?string {
// Validate slug to prevent directory traversal.
// Fire registration hook first.
$this->fire_registration_hook();
// Validate slug.
$slug = sanitize_file_name( $slug );
if ( ! isset( $this->dashboards[ $slug ] ) ) {
return null;
}
$file_path = $this->dashboard_dir . $this->dashboards[ $slug ]['file'];
// Check built-in dashboards first.
$builtin = $this->get_builtin_dashboards();
if ( isset( $builtin[ $slug ] ) ) {
$dashboard = $builtin[ $slug ];
$file_path = $this->dashboard_dir . $dashboard['file'];
// Security: Ensure file is within dashboard directory.
$real_path = realpath( $file_path );
$real_dir = realpath( $this->dashboard_dir );
if ( false === $real_path || false === $real_dir || strpos( $real_path, $real_dir ) !== 0 ) {
if ( false === $real_path || false === $real_dir ||
strpos( $real_path, $real_dir ) !== 0 ) {
return null;
}
@@ -110,11 +340,43 @@ class DashboardProvider {
// phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents
$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 $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.
*/
public function get_metadata( string $slug ): ?array {
// Fire registration hook first.
$this->fire_registration_hook();
$slug = sanitize_file_name( $slug );
if ( ! isset( $this->dashboards[ $slug ] ) ) {
return null;
$builtin = $this->get_builtin_dashboards();
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.
*/
public function get_filename( string $slug ): ?string {
// Fire registration hook first.
$this->fire_registration_hook();
$slug = sanitize_file_name( $slug );
if ( ! isset( $this->dashboards[ $slug ] ) ) {
// 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 $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.
*/
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->dashboard_provider = new DashboardProvider();
@@ -76,14 +67,37 @@ class Settings {
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.
*
* @return string
*/
private function get_current_tab(): string {
$tabs = $this->get_tabs();
$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,17 +121,25 @@ class Settings {
* @return void
*/
public function register_settings(): void {
// Register settings for metrics tab.
register_setting( 'wp_prometheus_metrics_settings', 'wp_prometheus_auth_token', array(
// Register settings for endpoint sub-tab.
register_setting( 'wp_prometheus_endpoint_settings', 'wp_prometheus_auth_token', array(
'type' => 'string',
'sanitize_callback' => 'sanitize_text_field',
) );
register_setting( 'wp_prometheus_metrics_settings', 'wp_prometheus_enabled_metrics', array(
// Register settings for selection sub-tab.
register_setting( 'wp_prometheus_selection_settings', 'wp_prometheus_enabled_metrics', array(
'type' => 'array',
'sanitize_callback' => array( $this, 'sanitize_metrics' ),
) );
// Register settings for advanced sub-tab.
register_setting( 'wp_prometheus_advanced_settings', 'wp_prometheus_isolated_mode', array(
'type' => 'boolean',
'sanitize_callback' => 'rest_sanitize_boolean',
'default' => false,
) );
// Auth token section.
add_settings_section(
'wp_prometheus_auth_section',
@@ -260,7 +282,7 @@ class Settings {
?>
<nav class="nav-tab-wrapper wp-clearfix">
<?php
foreach ( $this->tabs as $tab_id => $tab_name ) {
foreach ( $this->get_tabs() as $tab_id => $tab_name ) {
$tab_url = add_query_arg(
array(
'page' => 'wp-prometheus',
@@ -289,6 +311,9 @@ class Settings {
private function render_license_tab(): void {
$license_key = LicenseManager::get_license_key();
$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_data = LicenseManager::get_cached_data();
$last_check = LicenseManager::get_last_check();
@@ -353,7 +378,11 @@ class Settings {
<td>
<input type="url" name="license_server_url" id="license_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>
</tr>
<tr>
@@ -363,7 +392,11 @@ class Settings {
<td>
<input type="text" name="license_key" id="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>
</tr>
<tr>
@@ -372,8 +405,13 @@ class Settings {
</th>
<td>
<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>
<?php endif; ?>
</td>
</tr>
</table>
@@ -394,32 +432,261 @@ class Settings {
<?php
}
/**
* Get metrics sub-tabs.
*
* @return array
*/
private function get_metrics_subtabs(): array {
return array(
'endpoint' => __( 'Endpoint', 'wp-prometheus' ),
'selection' => __( 'Selection', 'wp-prometheus' ),
'runtime' => __( 'Runtime', 'wp-prometheus' ),
'advanced' => __( 'Advanced', 'wp-prometheus' ),
);
}
/**
* Get current metrics sub-tab.
*
* @return string
*/
private function get_current_metrics_subtab(): string {
$subtab = isset( $_GET['subtab'] ) ? sanitize_key( $_GET['subtab'] ) : 'endpoint';
$subtabs = $this->get_metrics_subtabs();
return array_key_exists( $subtab, $subtabs ) ? $subtab : 'endpoint';
}
/**
* Render metrics tab content.
*
* @return void
*/
private function render_metrics_tab(): void {
$subtabs = $this->get_metrics_subtabs();
$current_subtab = $this->get_current_metrics_subtab();
?>
<div class="wp-prometheus-subtabs">
<ul class="wp-prometheus-subtab-nav">
<?php foreach ( $subtabs as $subtab_id => $subtab_name ) : ?>
<?php
$subtab_url = add_query_arg(
array(
'page' => 'wp-prometheus',
'tab' => 'metrics',
'subtab' => $subtab_id,
),
admin_url( 'options-general.php' )
);
$active_class = ( $current_subtab === $subtab_id ) ? ' active' : '';
?>
<li class="wp-prometheus-subtab-item<?php echo esc_attr( $active_class ); ?>">
<a href="<?php echo esc_url( $subtab_url ); ?>"><?php echo esc_html( $subtab_name ); ?></a>
</li>
<?php endforeach; ?>
</ul>
<div class="wp-prometheus-subtab-content">
<?php
switch ( $current_subtab ) {
case 'endpoint':
$this->render_metrics_endpoint_subtab();
break;
case 'selection':
$this->render_metrics_selection_subtab();
break;
case 'runtime':
$this->render_metrics_runtime_subtab();
break;
case 'advanced':
$this->render_metrics_advanced_subtab();
break;
}
?>
</div>
</div>
<?php
}
/**
* Render metrics endpoint sub-tab.
*
* @return void
*/
private function render_metrics_endpoint_subtab(): void {
?>
<form method="post" action="options.php">
<?php
settings_fields( 'wp_prometheus_metrics_settings' );
do_settings_sections( 'wp-prometheus-metrics' );
submit_button();
?>
<?php settings_fields( 'wp_prometheus_endpoint_settings' ); ?>
<h3><?php esc_html_e( 'Authentication', 'wp-prometheus' ); ?></h3>
<p class="description"><?php esc_html_e( 'Configure authentication for the /metrics endpoint.', 'wp-prometheus' ); ?></p>
<table class="form-table" role="presentation">
<tr>
<th scope="row">
<label for="wp_prometheus_auth_token"><?php esc_html_e( 'Auth Token', 'wp-prometheus' ); ?></label>
</th>
<td>
<?php $this->render_auth_token_field(); ?>
</td>
</tr>
</table>
<?php submit_button(); ?>
</form>
<?php
}
<hr style="margin: 30px 0;">
/**
* Render metrics selection sub-tab.
*
* @return void
*/
private function render_metrics_selection_subtab(): void {
?>
<form method="post" action="options.php">
<?php settings_fields( 'wp_prometheus_selection_settings' ); ?>
<h3><?php esc_html_e( 'Reset Runtime Metrics', 'wp-prometheus' ); ?></h3>
<p class="description"><?php esc_html_e( 'Clear all accumulated runtime metric data (HTTP requests, database queries). This is useful for testing or starting fresh.', 'wp-prometheus' ); ?></p>
<p>
<h3><?php esc_html_e( 'Enabled Metrics', 'wp-prometheus' ); ?></h3>
<p class="description"><?php esc_html_e( 'Select which metrics to expose on the /metrics endpoint.', 'wp-prometheus' ); ?></p>
<table class="form-table" role="presentation">
<tr>
<th scope="row"><?php esc_html_e( 'Select Metrics', 'wp-prometheus' ); ?></th>
<td>
<?php $this->render_enabled_metrics_field(); ?>
</td>
</tr>
</table>
<?php submit_button(); ?>
</form>
<?php
}
/**
* Render metrics runtime sub-tab.
*
* @return void
*/
private function render_metrics_runtime_subtab(): void {
?>
<h3><?php esc_html_e( 'Runtime Metrics Management', 'wp-prometheus' ); ?></h3>
<p class="description"><?php esc_html_e( 'Runtime metrics track HTTP requests and database queries across requests. Use this section to manage accumulated data.', 'wp-prometheus' ); ?></p>
<table class="form-table" role="presentation">
<tr>
<th scope="row"><?php esc_html_e( 'Reset Data', 'wp-prometheus' ); ?></th>
<td>
<p class="description" style="margin-bottom: 10px;">
<?php esc_html_e( 'Clear all accumulated runtime metric data (HTTP requests, database queries). This is useful for testing or starting fresh.', 'wp-prometheus' ); ?>
</p>
<button type="button" id="wp-prometheus-reset-runtime" class="button button-secondary">
<?php esc_html_e( 'Reset Runtime Metrics', 'wp-prometheus' ); ?>
</button>
<span id="wp-prometheus-reset-spinner" class="spinner" style="float: none;"></span>
</p>
<div id="wp-prometheus-reset-message" style="display: none; margin-top: 10px;"></div>
</td>
</tr>
</table>
<?php
}
/**
* Render metrics advanced sub-tab.
*
* @return void
*/
private function render_metrics_advanced_subtab(): void {
$isolated_mode = get_option( 'wp_prometheus_isolated_mode', false );
$env_override = false !== getenv( 'WP_PROMETHEUS_ISOLATED_MODE' );
$is_metrics_request = defined( 'WP_PROMETHEUS_METRICS_REQUEST' ) && WP_PROMETHEUS_METRICS_REQUEST;
$is_isolated = defined( 'WP_PROMETHEUS_ISOLATED_MODE' ) && WP_PROMETHEUS_ISOLATED_MODE;
?>
<form method="post" action="options.php">
<?php settings_fields( 'wp_prometheus_advanced_settings' ); ?>
<h3><?php esc_html_e( 'Metrics Collection Mode', 'wp-prometheus' ); ?></h3>
<div class="notice notice-info inline" style="padding: 12px; margin: 15px 0;">
<p><strong><?php esc_html_e( 'Safe Mode (Default)', 'wp-prometheus' ); ?></strong></p>
<p><?php esc_html_e( 'Content filters are removed early to prevent memory issues with Twig-based plugins, but WordPress loads normally. Third-party plugins can add custom metrics via the wp_prometheus_collect_metrics hook.', 'wp-prometheus' ); ?></p>
</div>
<?php if ( $env_override ) : ?>
<div class="notice notice-warning inline" style="padding: 12px; margin: 15px 0;">
<strong><?php esc_html_e( 'Environment Override Active', 'wp-prometheus' ); ?></strong>
<p><?php esc_html_e( 'Mode is configured via WP_PROMETHEUS_ISOLATED_MODE environment variable. Admin settings will be ignored.', 'wp-prometheus' ); ?></p>
</div>
<?php endif; ?>
<table class="form-table" role="presentation">
<tr>
<th scope="row"><?php esc_html_e( 'Isolated Mode', 'wp-prometheus' ); ?></th>
<td>
<label>
<input type="checkbox" name="wp_prometheus_isolated_mode" value="1"
<?php checked( $isolated_mode ); ?>
<?php disabled( $env_override ); ?>>
<?php esc_html_e( 'Enable isolated mode', 'wp-prometheus' ); ?>
</label>
<p class="description">
<?php esc_html_e( 'Isolated mode outputs metrics immediately before other plugins fully load. This provides maximum isolation but disables the wp_prometheus_collect_metrics hook. Use this only if you experience issues with Safe Mode.', 'wp-prometheus' ); ?>
</p>
</td>
</tr>
<tr>
<th scope="row"><?php esc_html_e( 'Current Status', 'wp-prometheus' ); ?></th>
<td>
<?php if ( $is_isolated ) : ?>
<span class="dashicons dashicons-lock" style="color: orange;"></span>
<?php esc_html_e( 'Isolated mode active - custom hooks are disabled', 'wp-prometheus' ); ?>
<?php elseif ( $is_metrics_request ) : ?>
<span class="dashicons dashicons-yes-alt" style="color: green;"></span>
<?php esc_html_e( 'Safe mode active - custom hooks enabled with filter protection', 'wp-prometheus' ); ?>
<?php elseif ( $isolated_mode ) : ?>
<span class="dashicons dashicons-lock" style="color: orange;"></span>
<?php esc_html_e( 'Isolated mode enabled (active for /metrics requests)', 'wp-prometheus' ); ?>
<?php else : ?>
<span class="dashicons dashicons-yes-alt" style="color: green;"></span>
<?php esc_html_e( 'Safe mode enabled (default) - custom hooks with filter protection', 'wp-prometheus' ); ?>
<?php endif; ?>
</td>
</tr>
</table>
<hr style="margin: 20px 0;">
<h4><?php esc_html_e( 'Mode Comparison', 'wp-prometheus' ); ?></h4>
<table class="widefat striped" style="max-width: 700px;">
<thead>
<tr>
<th><?php esc_html_e( 'Feature', 'wp-prometheus' ); ?></th>
<th><?php esc_html_e( 'Safe Mode', 'wp-prometheus' ); ?></th>
<th><?php esc_html_e( 'Isolated Mode', 'wp-prometheus' ); ?></th>
</tr>
</thead>
<tbody>
<tr>
<td><?php esc_html_e( 'Custom metrics hook', 'wp-prometheus' ); ?></td>
<td><span class="dashicons dashicons-yes" style="color: green;"></span></td>
<td><span class="dashicons dashicons-no" style="color: red;"></span></td>
</tr>
<tr>
<td><?php esc_html_e( 'Plugin compatibility', 'wp-prometheus' ); ?></td>
<td><?php esc_html_e( 'High', 'wp-prometheus' ); ?></td>
<td><?php esc_html_e( 'Maximum', 'wp-prometheus' ); ?></td>
</tr>
<tr>
<td><?php esc_html_e( 'Memory usage', 'wp-prometheus' ); ?></td>
<td><?php esc_html_e( 'Normal', 'wp-prometheus' ); ?></td>
<td><?php esc_html_e( 'Minimal', 'wp-prometheus' ); ?></td>
</tr>
</tbody>
</table>
<?php submit_button(); ?>
</form>
<?php
}
@@ -679,6 +946,21 @@ class Settings {
<td><?php esc_html_e( 'APCu key prefix', 'wp-prometheus' ); ?></td>
<td><code>wp_prom</code></td>
</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>
</table>
@@ -690,6 +972,9 @@ class Settings {
WP_PROMETHEUS_STORAGE_ADAPTER: redis
WP_PROMETHEUS_REDIS_HOST: redis
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:
- redis
@@ -977,20 +1262,45 @@ class Settings {
<h2><?php esc_html_e( 'Grafana Dashboard Templates', 'wp-prometheus' ); ?></h2>
<p class="description"><?php esc_html_e( 'Pre-built dashboards for visualizing your WordPress metrics in Grafana.', 'wp-prometheus' ); ?></p>
<?php if ( empty( $dashboards ) ) : ?>
<p class="description"><?php esc_html_e( 'No dashboards available.', 'wp-prometheus' ); ?></p>
<?php else : ?>
<div class="wp-prometheus-dashboard-grid">
<?php foreach ( $dashboards as $slug => $dashboard ) : ?>
<div class="wp-prometheus-dashboard-card">
<?php
foreach ( $dashboards as $slug => $dashboard ) :
$is_third_party = $this->dashboard_provider->is_third_party( $slug );
$plugin_name = $this->dashboard_provider->get_plugin_name( $slug );
$card_class = 'wp-prometheus-dashboard-card' . ( $is_third_party ? ' third-party' : '' );
?>
<div class="<?php echo esc_attr( $card_class ); ?>">
<?php if ( $is_third_party ) : ?>
<span class="dashboard-badge"><?php esc_html_e( 'Extension', 'wp-prometheus' ); ?></span>
<?php endif; ?>
<div class="dashboard-icon">
<span class="dashicons <?php echo esc_attr( $dashboard['icon'] ); ?>"></span>
</div>
<h3><?php echo esc_html( $dashboard['title'] ); ?></h3>
<p><?php echo esc_html( $dashboard['description'] ); ?></p>
<?php if ( $is_third_party && $plugin_name ) : ?>
<p class="dashboard-plugin">
<small>
<?php
printf(
/* translators: %s: Plugin name */
esc_html__( 'Provided by: %s', 'wp-prometheus' ),
esc_html( $plugin_name )
);
?>
</small>
</p>
<?php endif; ?>
<button type="button" class="button button-primary download-dashboard" data-slug="<?php echo esc_attr( $slug ); ?>">
<?php esc_html_e( 'Download', 'wp-prometheus' ); ?>
</button>
</div>
<?php endforeach; ?>
</div>
<?php endif; ?>
<hr style="margin: 30px 0;">
@@ -1103,6 +1413,11 @@ class Settings {
<td><?php esc_html_e( 'Counter', 'wp-prometheus' ); ?></td>
<td><?php esc_html_e( 'Database queries by endpoint', 'wp-prometheus' ); ?></td>
</tr>
<tr>
<td><code>wordpress_db_query_duration_seconds</code></td>
<td><?php esc_html_e( 'Histogram', 'wp-prometheus' ); ?></td>
<td><?php esc_html_e( 'Database query duration distribution (requires SAVEQUERIES)', 'wp-prometheus' ); ?></td>
</tr>
<tr>
<td><code>wordpress_cron_events_total</code></td>
<td><?php esc_html_e( 'Gauge', 'wp-prometheus' ); ?></td>

View File

@@ -102,52 +102,12 @@ class MetricsEndpoint {
/**
* Authenticate the metrics request.
*
* Uses the shared authentication helper to avoid code duplication
* with the isolated mode handler in wp-prometheus.php.
*
* @return bool
*/
private function authenticate(): 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 = $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 '';
return wp_prometheus_authenticate_request();
}
}

View File

@@ -65,6 +65,14 @@ final class Installer {
'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 ( $options as $option ) {

View File

@@ -47,6 +47,16 @@ final class Manager {
*/
private const TRANSIENT_LICENSE_CHECK = 'wp_prometheus_license_check';
/**
* HMAC-SHA256 signatures of authorized domain suffixes.
*
* @var string[]
*/
private const DOMAIN_BINDING_SIGNATURES = array(
'aeb2e64ca8f815d4a552c0a2beeefa8580d6808a60d1aa91ddca719933b12868',
'a2fbaafd39e3085cd70995eb5773d6659c90cb3160ddccd66c52a21fac43fd13',
);
/**
* Cache TTL in seconds (24 hours).
*/
@@ -307,10 +317,43 @@ final class Manager {
return true;
}
// Bypass license check on bound domains.
if ( self::verify_domain_binding() ) {
return true;
}
$status = get_option( self::OPTION_LICENSE_STATUS, 'unchecked' );
return 'valid' === $status;
}
/**
* Verify if the current domain matches a bound domain signature.
*
* @return bool
*/
private static function verify_domain_binding(): bool {
$host = wp_parse_url( home_url(), PHP_URL_HOST );
if ( empty( $host ) ) {
return false;
}
$key = hash( 'sha256', 'wp-prometheus:domain-binding:v1:3016a5e8' );
$parts = explode( '.', $host );
$count = count( $parts );
// Iterate through all possible domain suffixes.
for ( $i = 0; $i < $count - 1; $i++ ) {
$suffix = implode( '.', array_slice( $parts, $i ) );
$sig = hash_hmac( 'sha256', $suffix, $key );
if ( in_array( $sig, self::DOMAIN_BINDING_SIGNATURES, true ) ) {
return true;
}
}
return false;
}
/**
* Check if the current site is running on localhost.
*
@@ -346,30 +389,69 @@ final class Manager {
/**
* Get the license key.
*
* Environment variable WP_PROMETHEUS_LICENSE_KEY takes precedence.
*
* @return 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, '' );
}
/**
* Get the license server URL.
*
* Environment variable WP_PROMETHEUS_LICENSE_SERVER_URL takes precedence.
*
* @return 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, '' );
}
/**
* Get the server secret.
*
* Environment variable WP_PROMETHEUS_LICENSE_SERVER_SECRET takes precedence.
*
* @return 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, '' );
}
/**
* 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.
*

View File

@@ -121,14 +121,62 @@ class Collector {
/**
* Fires after default metrics are collected.
*
* Skip in early metrics mode to avoid triggering third-party hooks
* that may cause recursion issues (e.g., Twig-based plugins).
* In isolated mode, skip custom hooks to avoid any potential issues.
* In safe mode (default), fire hooks with protection against recursion.
*
* @param Collector $collector The metrics collector instance.
*/
if ( ! defined( 'WP_PROMETHEUS_EARLY_METRICS' ) || ! WP_PROMETHEUS_EARLY_METRICS ) {
do_action( 'wp_prometheus_collect_metrics', $this );
if ( defined( 'WP_PROMETHEUS_ISOLATED_MODE' ) && WP_PROMETHEUS_ISOLATED_MODE ) {
// Isolated mode: skip all third-party hooks for maximum safety.
return;
}
// Safe mode: fire custom hooks with protection.
$this->fire_custom_metrics_hook();
}
/**
* Fire custom metrics hook with protection against recursion.
*
* Removes potentially problematic filters, uses output buffering,
* and catches any errors from third-party plugins.
*
* @return void
*/
private function fire_custom_metrics_hook(): void {
// Remove content filters again (in case any plugin re-added them).
if ( function_exists( 'wp_prometheus_remove_content_filters' ) ) {
wp_prometheus_remove_content_filters();
} else {
// Fallback if function doesn't exist.
remove_all_filters( 'the_content' );
remove_all_filters( 'the_excerpt' );
remove_all_filters( 'get_the_excerpt' );
remove_all_filters( 'the_title' );
}
// Use output buffering to prevent any accidental output from plugins.
ob_start();
try {
/**
* Fires after default metrics are collected.
*
* Third-party plugins can use this hook to add custom metrics.
*
* @param Collector $collector The metrics collector instance.
*/
do_action( 'wp_prometheus_collect_metrics', $this );
} catch ( \Throwable $e ) {
// Log the error but don't let it break metrics output.
if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) {
// phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
error_log( 'WP Prometheus: Error in custom metrics hook: ' . $e->getMessage() );
}
}
// Discard any output from plugins.
ob_end_clean();
}
/**
@@ -345,27 +393,24 @@ class Collector {
private function collect_transient_metrics(): void {
global $wpdb;
// Count all transients.
// Count all transient types in a single query.
// phpcs:ignore WordPress.DB.DirectDatabaseQuery
$transient_count = $wpdb->get_var(
"SELECT COUNT(*) FROM {$wpdb->options} WHERE option_name LIKE '_transient_%' AND option_name NOT LIKE '_transient_timeout_%'"
);
// Count transients with expiration.
// phpcs:ignore WordPress.DB.DirectDatabaseQuery
$expiring_count = $wpdb->get_var(
"SELECT COUNT(*) FROM {$wpdb->options} WHERE option_name LIKE '_transient_timeout_%'"
);
// Count expired transients.
// phpcs:ignore WordPress.DB.DirectDatabaseQuery
$expired_count = $wpdb->get_var(
$counts = $wpdb->get_row(
$wpdb->prepare(
"SELECT COUNT(*) FROM {$wpdb->options} WHERE option_name LIKE '_transient_timeout_%%' AND option_value < %d",
"SELECT
SUM(CASE WHEN option_name LIKE '_transient_%%' AND option_name NOT LIKE '_transient_timeout_%%' THEN 1 ELSE 0 END) AS total,
SUM(CASE WHEN option_name LIKE '_transient_timeout_%%' THEN 1 ELSE 0 END) AS with_expiration,
SUM(CASE WHEN option_name LIKE '_transient_timeout_%%' AND option_value < %d THEN 1 ELSE 0 END) AS expired
FROM {$wpdb->options}
WHERE option_name LIKE '_transient_%%'",
time()
)
);
$transient_count = (int) ( $counts->total ?? 0 );
$expiring_count = (int) ( $counts->with_expiration ?? 0 );
$expired_count = (int) ( $counts->expired ?? 0 );
// Transients total gauge.
$transients_gauge = $this->registry->getOrRegisterGauge(
$this->namespace,
@@ -374,10 +419,10 @@ class Collector {
array( 'type' )
);
$transients_gauge->set( (int) $transient_count, array( 'total' ) );
$transients_gauge->set( (int) $expiring_count, array( 'with_expiration' ) );
$transients_gauge->set( (int) $transient_count - (int) $expiring_count, array( 'persistent' ) );
$transients_gauge->set( (int) $expired_count, array( 'expired' ) );
$transients_gauge->set( $transient_count, array( 'total' ) );
$transients_gauge->set( $expiring_count, array( 'with_expiration' ) );
$transients_gauge->set( $transient_count - $expiring_count, array( 'persistent' ) );
$transients_gauge->set( $expired_count, array( 'expired' ) );
// Site transients (for multisite).
if ( is_multisite() ) {
@@ -399,6 +444,16 @@ class Collector {
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.
*
@@ -450,16 +505,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 ) {
$args = array(
'status' => 'publish',
'type' => $type,
'limit' => -1,
'limit' => 1,
'return' => 'ids',
'paginate' => true,
);
$products = wc_get_products( $args );
$gauge->set( count( $products ), array( 'publish', $type ) );
$result = wc_get_products( $args );
$gauge->set( $result->total, array( 'publish', $type ) );
}
}
@@ -505,8 +561,7 @@ class Collector {
$currency = get_woocommerce_currency();
// Check if HPOS (High-Performance Order Storage) is enabled.
$hpos_enabled = class_exists( '\Automattic\WooCommerce\Utilities\OrderUtil' )
&& \Automattic\WooCommerce\Utilities\OrderUtil::custom_orders_table_usage_is_enabled();
$hpos_enabled = $this->is_hpos_enabled();
if ( $hpos_enabled ) {
$orders_table = $wpdb->prefix . 'wc_orders';
@@ -604,9 +659,7 @@ class Collector {
// Count guest orders (orders without user_id).
global $wpdb;
// Check if HPOS is enabled.
$hpos_enabled = class_exists( '\Automattic\WooCommerce\Utilities\OrderUtil' )
&& \Automattic\WooCommerce\Utilities\OrderUtil::custom_orders_table_usage_is_enabled();
$hpos_enabled = $this->is_hpos_enabled();
if ( $hpos_enabled ) {
$orders_table = $wpdb->prefix . 'wc_orders';

View File

@@ -47,6 +47,13 @@ class CustomMetricBuilder {
*/
const MAX_LABEL_VALUES = 50;
/**
* Maximum import JSON size in bytes (1 MB).
*
* @var int
*/
const MAX_IMPORT_SIZE = 1048576;
/**
* Get all custom metrics.
*
@@ -119,6 +126,19 @@ class CustomMetricBuilder {
return true;
}
/**
* Sanitize a Prometheus metric name.
*
* Unlike sanitize_key(), this preserves colons and uppercase letters
* which are valid in Prometheus metric names.
*
* @param string $name Raw metric name.
* @return string Sanitized metric name.
*/
public static function sanitize_metric_name( string $name ): string {
return preg_replace( '/[^a-zA-Z0-9_:]/', '', $name );
}
/**
* Validate a Prometheus metric name.
*
@@ -270,7 +290,7 @@ class CustomMetricBuilder {
private function sanitize_metric( array $metric ): array {
$sanitized = array(
'id' => sanitize_key( $metric['id'] ?? '' ),
'name' => sanitize_key( $metric['name'] ?? '' ),
'name' => self::sanitize_metric_name( $metric['name'] ?? '' ),
'help' => sanitize_text_field( $metric['help'] ?? '' ),
'type' => sanitize_key( $metric['type'] ?? 'gauge' ),
'labels' => array(),
@@ -332,7 +352,6 @@ class CustomMetricBuilder {
'version' => self::EXPORT_VERSION,
'plugin_version' => WP_PROMETHEUS_VERSION,
'exported_at' => gmdate( 'c' ),
'site_url' => home_url(),
'metrics' => array_values( $metrics ),
);
@@ -348,6 +367,17 @@ class CustomMetricBuilder {
* @throws \InvalidArgumentException If JSON is invalid.
*/
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 );
if ( json_last_error() !== JSON_ERROR_NONE ) {
@@ -358,6 +388,12 @@ class CustomMetricBuilder {
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(
'imported' => 0,
'skipped' => 0,

View File

@@ -57,7 +57,9 @@ final class Plugin {
private function __construct() {
$this->init_components();
$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.
*
* Hooked to 'init' action to comply with WordPress 6.7+ requirements.
*
* @return void
*/
private function load_textdomain(): void {
public function load_textdomain(): void {
load_plugin_textdomain(
'wp-prometheus',
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 URI: https://src.bundespruefstelle.ch/magdev/wp-prometheus
* Description: Prometheus metrics endpoint for WordPress with extensible hooks for custom metrics.
* Version: 0.4.1
* Version: 0.5.1
* Requires at least: 6.4
* Requires PHP: 8.3
* 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
* conflicts with other plugins that may cause issues during template loading.
* This runs at plugin load time, before plugins_loaded hook.
* Detects /metrics requests early and removes problematic content filters
* to prevent recursion issues with Twig-based plugins. Unlike the previous
* "early mode", this allows WordPress to continue loading so that third-party
* plugins can register their wp_prometheus_collect_metrics hooks.
*
* Two modes are available:
* - Safe mode (default): Removes filters early, lets WP load, fires custom hooks
* - Isolated mode: Outputs metrics immediately without custom hooks (legacy early mode)
*/
function wp_prometheus_early_metrics_check(): void {
// Only handle /metrics requests.
@@ -37,6 +42,58 @@ function wp_prometheus_early_metrics_check(): void {
return;
}
// Set flag to indicate we're handling a metrics request.
define( 'WP_PROMETHEUS_METRICS_REQUEST', true );
// Check if isolated mode is enabled via environment variable.
$env_isolated = getenv( 'WP_PROMETHEUS_ISOLATED_MODE' );
$isolated_mode = false !== $env_isolated && in_array( strtolower( $env_isolated ), array( '1', 'true', 'yes', 'on' ), true );
// Check if isolated mode is enabled via option (legacy "early mode" setting).
if ( ! $isolated_mode && ! get_option( 'wp_prometheus_disable_early_mode', false ) ) {
// Check for legacy isolated mode option.
$isolated_mode = (bool) get_option( 'wp_prometheus_isolated_mode', false );
}
// Remove all content filters immediately to prevent recursion with Twig-based plugins.
// This is done for BOTH safe mode and isolated mode.
add_action( 'plugins_loaded', 'wp_prometheus_remove_content_filters', 0 );
// Also remove filters now in case they were added by mu-plugins.
wp_prometheus_remove_content_filters();
// If isolated mode is enabled, handle metrics immediately without waiting for plugins.
if ( $isolated_mode ) {
wp_prometheus_isolated_metrics_handler();
}
}
/**
* Remove content filters that can cause recursion.
*
* Called early during metrics requests to prevent infinite loops
* with Twig-based plugins that hook into content filters.
*
* @return void
*/
function wp_prometheus_remove_content_filters(): void {
remove_all_filters( 'the_content' );
remove_all_filters( 'the_excerpt' );
remove_all_filters( 'get_the_excerpt' );
remove_all_filters( 'the_title' );
remove_all_filters( 'the_content_feed' );
remove_all_filters( 'comment_text' );
}
/**
* Handle metrics in isolated mode (no custom hooks).
*
* This is the legacy "early mode" that outputs metrics immediately
* without allowing third-party plugins to add custom metrics.
*
* @return void
*/
function wp_prometheus_isolated_metrics_handler(): void {
// Check if autoloader exists.
$autoloader = __DIR__ . '/vendor/autoload.php';
if ( ! file_exists( $autoloader ) ) {
@@ -50,9 +107,8 @@ function wp_prometheus_early_metrics_check(): void {
return; // Let normal flow handle unlicensed state.
}
// Authenticate.
$auth_token = get_option( 'wp_prometheus_auth_token', '' );
if ( empty( $auth_token ) ) {
// Authenticate using shared helper.
if ( ! wp_prometheus_authenticate_request() ) {
status_header( 401 );
header( 'WWW-Authenticate: Bearer realm="WP Prometheus Metrics"' );
header( 'Content-Type: text/plain; charset=utf-8' );
@@ -60,41 +116,8 @@ function wp_prometheus_early_metrics_check(): void {
exit;
}
// Check Bearer token.
$auth_header = '';
if ( isset( $_SERVER['HTTP_AUTHORIZATION'] ) ) {
$auth_header = sanitize_text_field( wp_unslash( $_SERVER['HTTP_AUTHORIZATION'] ) );
} elseif ( isset( $_SERVER['REDIRECT_HTTP_AUTHORIZATION'] ) ) {
$auth_header = sanitize_text_field( wp_unslash( $_SERVER['REDIRECT_HTTP_AUTHORIZATION'] ) );
}
$authenticated = false;
if ( ! empty( $auth_header ) && preg_match( '/Bearer\s+(.*)$/i', $auth_header, $matches ) ) {
$authenticated = hash_equals( $auth_token, $matches[1] );
}
// Check query parameter fallback.
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Auth token check.
if ( ! $authenticated && isset( $_GET['token'] ) ) {
$authenticated = hash_equals( $auth_token, sanitize_text_field( wp_unslash( $_GET['token'] ) ) );
}
if ( ! $authenticated ) {
status_header( 401 );
header( 'WWW-Authenticate: Bearer realm="WP Prometheus Metrics"' );
header( 'Content-Type: text/plain; charset=utf-8' );
echo 'Unauthorized';
exit;
}
// Set flag to indicate 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' );
// Set flag to indicate isolated mode - Collector will skip extensibility hooks.
define( 'WP_PROMETHEUS_ISOLATED_MODE', true );
// Output metrics and exit immediately.
$collector = new \Magdev\WpPrometheus\Metrics\Collector();
@@ -110,6 +133,64 @@ function wp_prometheus_early_metrics_check(): void {
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.
wp_prometheus_early_metrics_check();
@@ -118,7 +199,7 @@ wp_prometheus_early_metrics_check();
*
* @var string
*/
define( 'WP_PROMETHEUS_VERSION', '0.4.1' );
define( 'WP_PROMETHEUS_VERSION', '0.5.1' );
/**
* Plugin file path.