You've already forked wp-prometheus
Compare commits
21 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 52fd6da1d1 | |||
| 9a94b4a7a5 | |||
| 1b1e818ff4 | |||
| 88ce597f1e | |||
| 9bfed06466 | |||
| b605d0c299 | |||
| 63660202c4 | |||
| 3b71a0f7c9 | |||
| 5aaa73ec24 | |||
| e5f2edbafa | |||
| 7f0b6ec8a6 | |||
| 192da4588a | |||
| cf1797d4bf | |||
| 19d75ab7b2 | |||
| fa63857f5f | |||
| 41f16a9fbd | |||
| f984e3eb23 | |||
| 898af5e9d2 | |||
| bad977bef0 | |||
| da6d5081f7 | |||
| 3eb66b0ebe |
@@ -6,7 +6,7 @@ on:
|
||||
- 'v*'
|
||||
|
||||
jobs:
|
||||
build-release:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
@@ -18,7 +18,32 @@ jobs:
|
||||
uses: shivammathur/setup-php@v2
|
||||
with:
|
||||
php-version: '8.3'
|
||||
extensions: mbstring, xml, zip, intl, gettext
|
||||
extensions: mbstring, xml, zip, intl, gettext, redis, apcu
|
||||
tools: composer:v2
|
||||
|
||||
- name: 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
|
||||
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: Get version from tag
|
||||
|
||||
6
.gitignore
vendored
6
.gitignore
vendored
@@ -4,3 +4,9 @@ wp-plugins
|
||||
wp-core
|
||||
vendor/
|
||||
releases/*
|
||||
|
||||
# Marketing texts (not for distribution)
|
||||
MARKETING.md
|
||||
|
||||
# PHPUnit cache
|
||||
.phpunit.cache
|
||||
|
||||
257
CHANGELOG.md
257
CHANGELOG.md
@@ -5,6 +5,263 @@ 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
|
||||
|
||||
- Fixed memory exhaustion when wp-fedistream (Twig-based) plugin is active
|
||||
- Added early metrics endpoint handler that intercepts `/metrics` requests before full WordPress initialization
|
||||
- Removed content filters (`the_content`, `the_excerpt`, `get_the_excerpt`, `the_title`) during metrics collection to prevent recursion
|
||||
- Skip third-party extensibility hooks during early metrics mode to avoid conflicts
|
||||
- Changed `template_redirect` hook to `parse_request` for earlier request interception
|
||||
|
||||
## [0.4.0] - 2026-02-02
|
||||
|
||||
### Added
|
||||
|
||||
- Persistent Storage Support:
|
||||
- Redis storage adapter for shared metrics across multiple instances
|
||||
- APCu storage adapter for single-server high-performance caching
|
||||
- StorageFactory class for automatic adapter selection and fallback
|
||||
- Connection testing with detailed error messages
|
||||
- New "Storage" tab in admin settings:
|
||||
- Storage adapter selection (In-Memory, Redis, APCu)
|
||||
- Redis configuration (host, port, password, database, key prefix)
|
||||
- APCu configuration (key prefix)
|
||||
- Connection test button
|
||||
- Environment variables documentation
|
||||
- Environment variable configuration for Docker/containerized environments:
|
||||
- `WP_PROMETHEUS_STORAGE_ADAPTER` - Select storage adapter
|
||||
- `WP_PROMETHEUS_REDIS_HOST` - Redis server hostname
|
||||
- `WP_PROMETHEUS_REDIS_PORT` - Redis server port
|
||||
- `WP_PROMETHEUS_REDIS_PASSWORD` - Redis authentication
|
||||
- `WP_PROMETHEUS_REDIS_DATABASE` - Redis database index (0-15)
|
||||
- `WP_PROMETHEUS_REDIS_PREFIX` - Redis key prefix
|
||||
- `WP_PROMETHEUS_APCU_PREFIX` - APCu key prefix
|
||||
- Automatic fallback to In-Memory storage if configured adapter fails
|
||||
- Docker Compose example in admin settings
|
||||
|
||||
### Changed
|
||||
|
||||
- Settings page now has 6 tabs: License, Metrics, Storage, Custom Metrics, Dashboards, Help
|
||||
- Updated translations with all new strings (English and German)
|
||||
- Collector now uses StorageFactory for storage adapter instantiation
|
||||
|
||||
## [0.3.0] - 2026-02-02
|
||||
|
||||
### Added
|
||||
|
||||
- Custom Metrics Builder:
|
||||
- Admin UI to define custom gauge metrics
|
||||
- Support for static values and WordPress option-based values
|
||||
- Label support with up to 5 labels and 50 value combinations
|
||||
- Metric validation following Prometheus naming conventions
|
||||
- Metric Export/Import:
|
||||
- JSON-based configuration export for backup
|
||||
- Import with three modes: skip existing, overwrite, or rename duplicates
|
||||
- Version tracking in export format
|
||||
- Grafana Dashboard Templates:
|
||||
- WordPress Overview dashboard (users, posts, comments, cron, transients)
|
||||
- WordPress Runtime dashboard (HTTP requests, database queries)
|
||||
- WordPress WooCommerce dashboard (orders, revenue, products, customers)
|
||||
- Easy download and import instructions
|
||||
- New "Custom Metrics" tab in admin settings
|
||||
- New "Dashboards" tab in admin settings
|
||||
- Reset runtime metrics button to clear accumulated data
|
||||
|
||||
### Changed
|
||||
|
||||
- Settings page now has 5 tabs: License, Metrics, Custom Metrics, Dashboards, Help
|
||||
- Updated translations with all new strings
|
||||
|
||||
## [0.2.2] - 2026-02-02
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed `wc_orders_count()` call missing required status parameter in WooCommerce orders metrics
|
||||
|
||||
## [0.2.1] - 2026-02-02
|
||||
|
||||
### Added
|
||||
|
||||
- Localhost license bypass for development environments (localhost, 127.0.0.1, ::1, \*.localhost, \*.local)
|
||||
- Automatic rewrite rules flush when license status changes
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed 404 error on `/metrics` endpoint when license becomes valid after plugin activation
|
||||
|
||||
## [0.2.0] - 2026-02-02
|
||||
|
||||
### Added
|
||||
|
||||
- WooCommerce integration metrics (when WooCommerce is active):
|
||||
- `wordpress_woocommerce_products_total` - Products by status and type
|
||||
- `wordpress_woocommerce_orders_total` - Orders by status
|
||||
- `wordpress_woocommerce_revenue_total` - Revenue (all time, today, month)
|
||||
- `wordpress_woocommerce_customers_total` - Customers (registered, guest)
|
||||
- Cron job metrics:
|
||||
- `wordpress_cron_events_total` - Scheduled cron events by hook
|
||||
- `wordpress_cron_overdue_total` - Number of overdue cron events
|
||||
- `wordpress_cron_next_run_timestamp` - Unix timestamp of next scheduled cron
|
||||
- Transient cache metrics:
|
||||
- `wordpress_transients_total` - Transients by type (total, with_expiration, persistent, expired)
|
||||
- WooCommerce metrics section in settings (only visible when WooCommerce is active)
|
||||
- Support for WooCommerce HPOS (High-Performance Order Storage)
|
||||
|
||||
### Changed
|
||||
|
||||
- Updated Help tab with new metrics reference
|
||||
|
||||
## [0.1.1] - 2026-02-02
|
||||
|
||||
### Changed
|
||||
|
||||
330
CLAUDE.md
330
CLAUDE.md
@@ -15,8 +15,15 @@ This plugin provides a Prometheus `/metrics` endpoint and an extensible way to a
|
||||
- Prometheus compatible authenticated `/metrics` endpoint
|
||||
- Optional default metrics (users, posts, comments, plugins)
|
||||
- Runtime metrics (HTTP requests, request duration, database queries)
|
||||
- Cron job metrics (scheduled events, overdue, next run)
|
||||
- Transient cache metrics (total, expiring, expired)
|
||||
- WooCommerce integration (products, orders, revenue, customers)
|
||||
- Custom metric builder with admin UI (gauges with static or option-based values)
|
||||
- Metric export/import for backup and site migration
|
||||
- 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
|
||||
@@ -27,11 +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.
|
||||
|
||||
### Version 0.2.0 (Planned)
|
||||
### Known Bugs
|
||||
|
||||
- WooCommerce integration metrics
|
||||
- Cron job metrics
|
||||
- Transient cache metrics
|
||||
*No known bugs at this time.*
|
||||
|
||||
## Technical Stack
|
||||
|
||||
@@ -76,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
|
||||
|
||||
@@ -208,9 +209,13 @@ 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
|
||||
│ │ ├── wordpress-overview.json
|
||||
│ │ ├── wordpress-runtime.json
|
||||
│ │ └── wordpress-woocommerce.json
|
||||
│ └── js/
|
||||
│ └── admin.js # Admin JavaScript
|
||||
├── languages/ # Translation files
|
||||
@@ -219,6 +224,7 @@ wp-prometheus/
|
||||
├── releases/ # Release packages
|
||||
├── src/
|
||||
│ ├── Admin/
|
||||
│ │ ├── DashboardProvider.php # Grafana dashboard provider
|
||||
│ │ └── Settings.php # Settings page
|
||||
│ ├── Endpoint/
|
||||
│ │ └── MetricsEndpoint.php # /metrics endpoint
|
||||
@@ -226,14 +232,34 @@ wp-prometheus/
|
||||
│ │ └── Manager.php # License management
|
||||
│ ├── Metrics/
|
||||
│ │ ├── Collector.php # Prometheus metrics collector
|
||||
│ │ └── RuntimeCollector.php # Runtime metrics collector
|
||||
│ │ ├── CustomMetricBuilder.php # Custom metric CRUD
|
||||
│ │ ├── RuntimeCollector.php # Runtime metrics collector
|
||||
│ │ └── StorageFactory.php # Storage adapter factory
|
||||
│ ├── 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
|
||||
@@ -282,6 +308,288 @@ 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:
|
||||
- `StorageFactory.php` - Factory class for storage adapter instantiation
|
||||
- Redis storage adapter for shared metrics across multiple instances
|
||||
- APCu storage adapter for single-server high-performance caching
|
||||
- Automatic fallback to In-Memory if configured adapter fails
|
||||
- Added new "Storage" tab in admin settings:
|
||||
- Storage adapter selection (In-Memory, Redis, APCu)
|
||||
- Redis configuration (host, port, password, database, key prefix)
|
||||
- APCu configuration (key prefix)
|
||||
- Connection test button with detailed error messages
|
||||
- Added environment variable support for Docker deployments:
|
||||
- `WP_PROMETHEUS_STORAGE_ADAPTER` - Adapter selection
|
||||
- `WP_PROMETHEUS_REDIS_HOST`, `_PORT`, `_PASSWORD`, `_DATABASE`, `_PREFIX`
|
||||
- `WP_PROMETHEUS_APCU_PREFIX`
|
||||
- Environment variables take precedence over admin settings
|
||||
- Updated `Collector.php` to use `StorageFactory::get_adapter()`
|
||||
- Updated Help tab with storage backends documentation
|
||||
- Updated translation files with all new strings
|
||||
- **Key Learning**: promphp/prometheus_client_php storage adapters
|
||||
- Redis adapter requires options array with host, port, password, timeout
|
||||
- APCu adapter just needs a prefix string
|
||||
- Use `Redis::setPrefix()` before instantiation for custom key prefixes
|
||||
- **Key Learning**: Docker environment variable configuration
|
||||
- Use `getenv()` with explicit false check (`false !== getenv()`)
|
||||
- Environment variables should override WordPress options for containerized deployments
|
||||
|
||||
### 2026-02-02 - Custom Metrics & Dashboards (v0.3.0)
|
||||
|
||||
- Added Custom Metric Builder with full admin UI:
|
||||
- `CustomMetricBuilder.php` - CRUD operations, validation, export/import
|
||||
- Support for static values and WordPress option-based values
|
||||
- Label support (max 5 labels, 50 value combinations)
|
||||
- Prometheus naming convention validation (`[a-zA-Z_:][a-zA-Z0-9_:]*`)
|
||||
- Added Grafana Dashboard Templates:
|
||||
- `DashboardProvider.php` - Dashboard file provider with path traversal protection
|
||||
- `wordpress-overview.json` - General WordPress metrics
|
||||
- `wordpress-runtime.json` - HTTP/DB performance metrics
|
||||
- `wordpress-woocommerce.json` - WooCommerce store metrics
|
||||
- Added export/import functionality:
|
||||
- JSON-based configuration export
|
||||
- Three import modes: skip, overwrite, rename duplicates
|
||||
- Version tracking in export format
|
||||
- Updated Settings page with new tabs:
|
||||
- "Custom Metrics" tab with metric form and table
|
||||
- "Dashboards" tab with download buttons
|
||||
- "Reset Runtime Metrics" button in Metrics tab
|
||||
- Updated `Collector.php` to integrate custom metrics
|
||||
- Updated translation files with all new strings
|
||||
- **Key Learning**: Dynamic form handling in WordPress admin
|
||||
- Use `wp_create_nonce()` with unique nonce names per AJAX action
|
||||
- Localize script with `wp_localize_script()` for nonces and AJAX URL
|
||||
- Always verify `current_user_can('manage_options')` in AJAX handlers
|
||||
- **Key Learning**: Grafana dashboard JSON format
|
||||
- Use `${DS_PROMETHEUS}` for data source variable
|
||||
- Schema version 39 for current Grafana compatibility
|
||||
- Panels use `gridPos` for layout positioning
|
||||
|
||||
### 2026-02-02 - Extended Metrics (v0.2.0)
|
||||
|
||||
- Added WooCommerce integration metrics (only when WooCommerce is active):
|
||||
- `wordpress_woocommerce_products_total` - Products by status and type
|
||||
- `wordpress_woocommerce_orders_total` - Orders by status
|
||||
- `wordpress_woocommerce_revenue_total` - Revenue (all time, today, month)
|
||||
- `wordpress_woocommerce_customers_total` - Customers (registered, guest)
|
||||
- Added cron job metrics:
|
||||
- `wordpress_cron_events_total` - Scheduled cron events by hook (top 20)
|
||||
- `wordpress_cron_overdue_total` - Number of overdue cron events
|
||||
- `wordpress_cron_next_run_timestamp` - Unix timestamp of next scheduled cron
|
||||
- Added transient cache metrics:
|
||||
- `wordpress_transients_total` - Transients by type (total, with_expiration, persistent, expired)
|
||||
- Updated Settings page with new metric categories
|
||||
- Updated Help tab with new metrics reference
|
||||
- **Key Learning**: WooCommerce HPOS (High-Performance Order Storage) requires different queries
|
||||
- Check `OrderUtil::custom_orders_table_usage_is_enabled()` to determine storage type
|
||||
- HPOS uses `wc_orders` table instead of `posts` and `postmeta`
|
||||
- **Key Learning**: Cron event labeling requires cardinality control
|
||||
- Limit to top 20 hooks to prevent label explosion
|
||||
- Use `arsort()` to get most frequent hooks first
|
||||
|
||||
### 2026-02-02 - Runtime Metrics (v0.1.0)
|
||||
|
||||
- Implemented runtime metrics collection for HTTP requests and database queries
|
||||
|
||||
67
PLAN.md
67
PLAN.md
@@ -59,6 +59,7 @@ wp-prometheus/
|
||||
│ └── release.yml # CI/CD pipeline
|
||||
├── assets/
|
||||
│ ├── css/ # Admin/Frontend styles
|
||||
│ ├── dashboards/ # Grafana dashboard templates
|
||||
│ └── js/
|
||||
│ └── admin.js # Admin JavaScript
|
||||
├── languages/ # Translation files
|
||||
@@ -67,13 +68,17 @@ wp-prometheus/
|
||||
├── releases/ # Release packages
|
||||
├── src/
|
||||
│ ├── Admin/
|
||||
│ │ ├── DashboardProvider.php
|
||||
│ │ └── Settings.php
|
||||
│ ├── Endpoint/
|
||||
│ │ └── MetricsEndpoint.php
|
||||
│ ├── License/
|
||||
│ │ └── Manager.php
|
||||
│ ├── Metrics/
|
||||
│ │ └── Collector.php
|
||||
│ │ ├── Collector.php
|
||||
│ │ ├── CustomMetricBuilder.php
|
||||
│ │ ├── RuntimeCollector.php
|
||||
│ │ └── StorageFactory.php
|
||||
│ ├── Installer.php
|
||||
│ ├── Plugin.php
|
||||
│ └── index.php
|
||||
@@ -159,19 +164,57 @@ Alternatively, the token can be passed as a query parameter (for testing):
|
||||
https://example.com/metrics/?token=your-auth-token
|
||||
```
|
||||
|
||||
## Storage Configuration
|
||||
|
||||
The plugin supports multiple storage backends for Prometheus metrics:
|
||||
|
||||
### Available Adapters
|
||||
|
||||
| Adapter | Description | Use Case |
|
||||
| --------- | ------------------------------- | ------------------------------------- |
|
||||
| In-Memory | Default, no persistence | Development, single request metrics |
|
||||
| Redis | Shared storage across instances | Production, load-balanced environments|
|
||||
| APCu | High-performance local cache | Production, single-server deployments |
|
||||
|
||||
### Environment Variables
|
||||
|
||||
For Docker or containerized environments, configure storage via environment variables:
|
||||
|
||||
```bash
|
||||
# Storage adapter selection
|
||||
WP_PROMETHEUS_STORAGE_ADAPTER=redis
|
||||
|
||||
# Redis configuration
|
||||
WP_PROMETHEUS_REDIS_HOST=redis
|
||||
WP_PROMETHEUS_REDIS_PORT=6379
|
||||
WP_PROMETHEUS_REDIS_PASSWORD=secret
|
||||
WP_PROMETHEUS_REDIS_DATABASE=0
|
||||
WP_PROMETHEUS_REDIS_PREFIX=WORDPRESS_PROMETHEUS_
|
||||
|
||||
# APCu configuration
|
||||
WP_PROMETHEUS_APCU_PREFIX=wp_prom
|
||||
```
|
||||
|
||||
### Docker Compose Example
|
||||
|
||||
```yaml
|
||||
services:
|
||||
wordpress:
|
||||
image: wordpress:latest
|
||||
environment:
|
||||
WP_PROMETHEUS_STORAGE_ADAPTER: redis
|
||||
WP_PROMETHEUS_REDIS_HOST: redis
|
||||
WP_PROMETHEUS_REDIS_PORT: 6379
|
||||
depends_on:
|
||||
- redis
|
||||
|
||||
redis:
|
||||
image: redis:alpine
|
||||
```
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
### Version 0.2.0
|
||||
|
||||
- WooCommerce integration metrics
|
||||
- Cron job metrics
|
||||
- Transient cache metrics
|
||||
|
||||
### Version 0.3.0
|
||||
|
||||
- Custom metric builder in admin
|
||||
- Metric export/import
|
||||
- Grafana dashboard templates
|
||||
*No planned features at this time.*
|
||||
|
||||
## Dependencies
|
||||
|
||||
|
||||
133
README.md
133
README.md
@@ -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,48 @@ 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 |
|
||||
|--------|------|--------|-------------|
|
||||
| wordpress_cron_events_total | Gauge | hook | Scheduled cron events by hook |
|
||||
| wordpress_cron_overdue_total | Gauge | - | Number of overdue cron events |
|
||||
| wordpress_cron_next_run_timestamp | Gauge | - | Unix timestamp of next scheduled cron |
|
||||
|
||||
### Transient Metrics (v0.2.0+)
|
||||
|
||||
| Metric | Type | Labels | Description |
|
||||
|--------|------|--------|-------------|
|
||||
| wordpress_transients_total | Gauge | type | Transients by type (total, with_expiration, persistent, expired) |
|
||||
|
||||
### WooCommerce Metrics (v0.2.0+)
|
||||
|
||||
These metrics are only available when WooCommerce is active.
|
||||
|
||||
| Metric | Type | Labels | Description |
|
||||
|--------|------|--------|-------------|
|
||||
| wordpress_woocommerce_products_total | Gauge | status, type | Products by status and type |
|
||||
| wordpress_woocommerce_orders_total | Gauge | status | Orders by status |
|
||||
| wordpress_woocommerce_revenue_total | Gauge | period, currency | Revenue (all_time, today, month) |
|
||||
| wordpress_woocommerce_customers_total | Gauge | type | Customers (registered, guest) |
|
||||
|
||||
**Note:** WooCommerce metrics support both legacy post-based orders and HPOS (High-Performance Order Storage).
|
||||
|
||||
## Extending with Custom Metrics
|
||||
|
||||
Add your own metrics using the `wp_prometheus_collect_metrics` action:
|
||||
@@ -127,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
|
||||
|
||||
@@ -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;
|
||||
@@ -45,3 +100,189 @@
|
||||
.wp-prometheus-tab-content .form-table {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
/* Custom metrics form */
|
||||
.wp-prometheus-metric-form {
|
||||
background: #fff;
|
||||
border: 1px solid #ccd0d4;
|
||||
border-radius: 4px;
|
||||
padding: 20px;
|
||||
margin: 20px 0;
|
||||
}
|
||||
|
||||
.wp-prometheus-metric-form h3 {
|
||||
margin-top: 0;
|
||||
padding-bottom: 10px;
|
||||
border-bottom: 1px solid #eee;
|
||||
}
|
||||
|
||||
.wp-prometheus-metric-form .required {
|
||||
color: #d63638;
|
||||
}
|
||||
|
||||
/* Label rows */
|
||||
.metric-label-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.metric-label-row input {
|
||||
flex: 1;
|
||||
max-width: 300px;
|
||||
}
|
||||
|
||||
.metric-label-row .remove-label {
|
||||
padding: 0 8px;
|
||||
min-width: auto;
|
||||
}
|
||||
|
||||
/* Value rows */
|
||||
.metric-value-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.metric-value-row input {
|
||||
width: 120px;
|
||||
}
|
||||
|
||||
.metric-value-row .remove-value-row {
|
||||
padding: 0 8px;
|
||||
min-width: auto;
|
||||
}
|
||||
|
||||
/* Custom metrics table */
|
||||
.wp-prometheus-custom-metrics .wp-list-table code {
|
||||
background: #f0f0f1;
|
||||
padding: 2px 6px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.wp-prometheus-custom-metrics .wp-list-table td {
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.wp-prometheus-custom-metrics .wp-list-table .dashicons {
|
||||
font-size: 18px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
/* Dashboard grid */
|
||||
.wp-prometheus-dashboard-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||
gap: 20px;
|
||||
margin: 20px 0;
|
||||
}
|
||||
|
||||
.wp-prometheus-dashboard-card {
|
||||
background: #fff;
|
||||
border: 1px solid #ccd0d4;
|
||||
border-radius: 4px;
|
||||
padding: 20px;
|
||||
text-align: center;
|
||||
transition: box-shadow 0.2s ease;
|
||||
}
|
||||
|
||||
.wp-prometheus-dashboard-card:hover {
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.wp-prometheus-dashboard-card .dashboard-icon {
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.wp-prometheus-dashboard-card .dashboard-icon .dashicons {
|
||||
font-size: 48px;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
color: #2271b1;
|
||||
}
|
||||
|
||||
.wp-prometheus-dashboard-card h3 {
|
||||
margin: 0 0 10px 0;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.wp-prometheus-dashboard-card p {
|
||||
color: #646970;
|
||||
margin: 0 0 15px 0;
|
||||
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;
|
||||
}
|
||||
|
||||
#import-options label {
|
||||
display: block;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
/* Spinner alignment */
|
||||
.spinner {
|
||||
float: none;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
/* Button groups */
|
||||
.wp-prometheus-tab-content .button + .button {
|
||||
margin-left: 5px;
|
||||
}
|
||||
|
||||
/* Notice messages in forms */
|
||||
.wp-prometheus-metric-form .notice,
|
||||
.wp-prometheus-custom-metrics .notice {
|
||||
margin: 10px 0;
|
||||
padding: 10px 15px;
|
||||
}
|
||||
|
||||
/* Responsive adjustments */
|
||||
@media screen and (max-width: 782px) {
|
||||
.wp-prometheus-dashboard-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.metric-value-row {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.metric-value-row input {
|
||||
width: 100%;
|
||||
max-width: none;
|
||||
}
|
||||
}
|
||||
|
||||
851
assets/dashboards/wordpress-overview.json
Normal file
851
assets/dashboards/wordpress-overview.json
Normal file
@@ -0,0 +1,851 @@
|
||||
{
|
||||
"annotations": {
|
||||
"list": []
|
||||
},
|
||||
"editable": true,
|
||||
"fiscalYearStartMonth": 0,
|
||||
"graphTooltip": 0,
|
||||
"id": null,
|
||||
"links": [],
|
||||
"panels": [
|
||||
{
|
||||
"collapsed": false,
|
||||
"gridPos": {
|
||||
"h": 1,
|
||||
"w": 24,
|
||||
"x": 0,
|
||||
"y": 0
|
||||
},
|
||||
"id": 1,
|
||||
"panels": [],
|
||||
"title": "System Info",
|
||||
"type": "row"
|
||||
},
|
||||
{
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "${DS_PROMETHEUS}"
|
||||
},
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"color": {
|
||||
"mode": "thresholds"
|
||||
},
|
||||
"mappings": [],
|
||||
"thresholds": {
|
||||
"mode": "absolute",
|
||||
"steps": [
|
||||
{
|
||||
"color": "green",
|
||||
"value": null
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"gridPos": {
|
||||
"h": 4,
|
||||
"w": 6,
|
||||
"x": 0,
|
||||
"y": 1
|
||||
},
|
||||
"id": 2,
|
||||
"options": {
|
||||
"colorMode": "value",
|
||||
"graphMode": "none",
|
||||
"justifyMode": "auto",
|
||||
"orientation": "auto",
|
||||
"reduceOptions": {
|
||||
"calcs": ["lastNotNull"],
|
||||
"fields": "/^version$/",
|
||||
"values": false
|
||||
},
|
||||
"textMode": "value"
|
||||
},
|
||||
"pluginVersion": "10.0.0",
|
||||
"targets": [
|
||||
{
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "${DS_PROMETHEUS}"
|
||||
},
|
||||
"editorMode": "code",
|
||||
"expr": "wordpress_info",
|
||||
"format": "table",
|
||||
"instant": true,
|
||||
"legendFormat": "__auto",
|
||||
"refId": "A"
|
||||
}
|
||||
],
|
||||
"title": "WordPress Version",
|
||||
"type": "stat"
|
||||
},
|
||||
{
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "${DS_PROMETHEUS}"
|
||||
},
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"color": {
|
||||
"mode": "thresholds"
|
||||
},
|
||||
"mappings": [],
|
||||
"thresholds": {
|
||||
"mode": "absolute",
|
||||
"steps": [
|
||||
{
|
||||
"color": "green",
|
||||
"value": null
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"gridPos": {
|
||||
"h": 4,
|
||||
"w": 6,
|
||||
"x": 6,
|
||||
"y": 1
|
||||
},
|
||||
"id": 3,
|
||||
"options": {
|
||||
"colorMode": "value",
|
||||
"graphMode": "none",
|
||||
"justifyMode": "auto",
|
||||
"orientation": "auto",
|
||||
"reduceOptions": {
|
||||
"calcs": ["lastNotNull"],
|
||||
"fields": "/^php_version$/",
|
||||
"values": false
|
||||
},
|
||||
"textMode": "value"
|
||||
},
|
||||
"pluginVersion": "10.0.0",
|
||||
"targets": [
|
||||
{
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "${DS_PROMETHEUS}"
|
||||
},
|
||||
"editorMode": "code",
|
||||
"expr": "wordpress_info",
|
||||
"format": "table",
|
||||
"instant": true,
|
||||
"legendFormat": "__auto",
|
||||
"refId": "A"
|
||||
}
|
||||
],
|
||||
"title": "PHP Version",
|
||||
"type": "stat"
|
||||
},
|
||||
{
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "${DS_PROMETHEUS}"
|
||||
},
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"color": {
|
||||
"mode": "thresholds"
|
||||
},
|
||||
"mappings": [],
|
||||
"thresholds": {
|
||||
"mode": "absolute",
|
||||
"steps": [
|
||||
{
|
||||
"color": "blue",
|
||||
"value": null
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"gridPos": {
|
||||
"h": 4,
|
||||
"w": 6,
|
||||
"x": 12,
|
||||
"y": 1
|
||||
},
|
||||
"id": 4,
|
||||
"options": {
|
||||
"colorMode": "value",
|
||||
"graphMode": "none",
|
||||
"justifyMode": "auto",
|
||||
"orientation": "auto",
|
||||
"reduceOptions": {
|
||||
"calcs": ["lastNotNull"],
|
||||
"fields": "",
|
||||
"values": false
|
||||
},
|
||||
"textMode": "auto"
|
||||
},
|
||||
"pluginVersion": "10.0.0",
|
||||
"targets": [
|
||||
{
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "${DS_PROMETHEUS}"
|
||||
},
|
||||
"editorMode": "code",
|
||||
"expr": "sum(wordpress_users_total)",
|
||||
"instant": true,
|
||||
"legendFormat": "__auto",
|
||||
"refId": "A"
|
||||
}
|
||||
],
|
||||
"title": "Total Users",
|
||||
"type": "stat"
|
||||
},
|
||||
{
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "${DS_PROMETHEUS}"
|
||||
},
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"color": {
|
||||
"mode": "thresholds"
|
||||
},
|
||||
"mappings": [],
|
||||
"thresholds": {
|
||||
"mode": "absolute",
|
||||
"steps": [
|
||||
{
|
||||
"color": "blue",
|
||||
"value": null
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"gridPos": {
|
||||
"h": 4,
|
||||
"w": 6,
|
||||
"x": 18,
|
||||
"y": 1
|
||||
},
|
||||
"id": 5,
|
||||
"options": {
|
||||
"colorMode": "value",
|
||||
"graphMode": "none",
|
||||
"justifyMode": "auto",
|
||||
"orientation": "auto",
|
||||
"reduceOptions": {
|
||||
"calcs": ["lastNotNull"],
|
||||
"fields": "",
|
||||
"values": false
|
||||
},
|
||||
"textMode": "auto"
|
||||
},
|
||||
"pluginVersion": "10.0.0",
|
||||
"targets": [
|
||||
{
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "${DS_PROMETHEUS}"
|
||||
},
|
||||
"editorMode": "code",
|
||||
"expr": "wordpress_plugins_total{status=\"active\"}",
|
||||
"instant": true,
|
||||
"legendFormat": "__auto",
|
||||
"refId": "A"
|
||||
}
|
||||
],
|
||||
"title": "Active Plugins",
|
||||
"type": "stat"
|
||||
},
|
||||
{
|
||||
"collapsed": false,
|
||||
"gridPos": {
|
||||
"h": 1,
|
||||
"w": 24,
|
||||
"x": 0,
|
||||
"y": 5
|
||||
},
|
||||
"id": 6,
|
||||
"panels": [],
|
||||
"title": "Content",
|
||||
"type": "row"
|
||||
},
|
||||
{
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "${DS_PROMETHEUS}"
|
||||
},
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"color": {
|
||||
"mode": "palette-classic"
|
||||
},
|
||||
"custom": {
|
||||
"hideFrom": {
|
||||
"legend": false,
|
||||
"tooltip": false,
|
||||
"viz": false
|
||||
}
|
||||
},
|
||||
"mappings": []
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"gridPos": {
|
||||
"h": 8,
|
||||
"w": 8,
|
||||
"x": 0,
|
||||
"y": 6
|
||||
},
|
||||
"id": 7,
|
||||
"options": {
|
||||
"legend": {
|
||||
"displayMode": "list",
|
||||
"placement": "right",
|
||||
"showLegend": true
|
||||
},
|
||||
"pieType": "pie",
|
||||
"reduceOptions": {
|
||||
"calcs": ["lastNotNull"],
|
||||
"fields": "",
|
||||
"values": false
|
||||
},
|
||||
"tooltip": {
|
||||
"mode": "single",
|
||||
"sort": "none"
|
||||
}
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "${DS_PROMETHEUS}"
|
||||
},
|
||||
"editorMode": "code",
|
||||
"expr": "wordpress_users_total",
|
||||
"instant": true,
|
||||
"legendFormat": "{{role}}",
|
||||
"refId": "A"
|
||||
}
|
||||
],
|
||||
"title": "Users by Role",
|
||||
"type": "piechart"
|
||||
},
|
||||
{
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "${DS_PROMETHEUS}"
|
||||
},
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"color": {
|
||||
"mode": "palette-classic"
|
||||
},
|
||||
"custom": {
|
||||
"hideFrom": {
|
||||
"legend": false,
|
||||
"tooltip": false,
|
||||
"viz": false
|
||||
}
|
||||
},
|
||||
"mappings": []
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"gridPos": {
|
||||
"h": 8,
|
||||
"w": 8,
|
||||
"x": 8,
|
||||
"y": 6
|
||||
},
|
||||
"id": 8,
|
||||
"options": {
|
||||
"legend": {
|
||||
"displayMode": "list",
|
||||
"placement": "right",
|
||||
"showLegend": true
|
||||
},
|
||||
"pieType": "pie",
|
||||
"reduceOptions": {
|
||||
"calcs": ["lastNotNull"],
|
||||
"fields": "",
|
||||
"values": false
|
||||
},
|
||||
"tooltip": {
|
||||
"mode": "single",
|
||||
"sort": "none"
|
||||
}
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "${DS_PROMETHEUS}"
|
||||
},
|
||||
"editorMode": "code",
|
||||
"expr": "wordpress_posts_total{post_type=\"post\"}",
|
||||
"instant": true,
|
||||
"legendFormat": "{{status}}",
|
||||
"refId": "A"
|
||||
}
|
||||
],
|
||||
"title": "Posts by Status",
|
||||
"type": "piechart"
|
||||
},
|
||||
{
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "${DS_PROMETHEUS}"
|
||||
},
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"color": {
|
||||
"mode": "palette-classic"
|
||||
},
|
||||
"custom": {
|
||||
"hideFrom": {
|
||||
"legend": false,
|
||||
"tooltip": false,
|
||||
"viz": false
|
||||
}
|
||||
},
|
||||
"mappings": []
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"gridPos": {
|
||||
"h": 8,
|
||||
"w": 8,
|
||||
"x": 16,
|
||||
"y": 6
|
||||
},
|
||||
"id": 9,
|
||||
"options": {
|
||||
"legend": {
|
||||
"displayMode": "list",
|
||||
"placement": "right",
|
||||
"showLegend": true
|
||||
},
|
||||
"pieType": "pie",
|
||||
"reduceOptions": {
|
||||
"calcs": ["lastNotNull"],
|
||||
"fields": "",
|
||||
"values": false
|
||||
},
|
||||
"tooltip": {
|
||||
"mode": "single",
|
||||
"sort": "none"
|
||||
}
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "${DS_PROMETHEUS}"
|
||||
},
|
||||
"editorMode": "code",
|
||||
"expr": "wordpress_comments_total{status!=\"total_comments\"}",
|
||||
"instant": true,
|
||||
"legendFormat": "{{status}}",
|
||||
"refId": "A"
|
||||
}
|
||||
],
|
||||
"title": "Comments by Status",
|
||||
"type": "piechart"
|
||||
},
|
||||
{
|
||||
"collapsed": false,
|
||||
"gridPos": {
|
||||
"h": 1,
|
||||
"w": 24,
|
||||
"x": 0,
|
||||
"y": 14
|
||||
},
|
||||
"id": 10,
|
||||
"panels": [],
|
||||
"title": "System Health",
|
||||
"type": "row"
|
||||
},
|
||||
{
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "${DS_PROMETHEUS}"
|
||||
},
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"color": {
|
||||
"mode": "thresholds"
|
||||
},
|
||||
"mappings": [],
|
||||
"thresholds": {
|
||||
"mode": "absolute",
|
||||
"steps": [
|
||||
{
|
||||
"color": "green",
|
||||
"value": null
|
||||
},
|
||||
{
|
||||
"color": "yellow",
|
||||
"value": 5
|
||||
},
|
||||
{
|
||||
"color": "red",
|
||||
"value": 10
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"gridPos": {
|
||||
"h": 4,
|
||||
"w": 6,
|
||||
"x": 0,
|
||||
"y": 15
|
||||
},
|
||||
"id": 11,
|
||||
"options": {
|
||||
"colorMode": "value",
|
||||
"graphMode": "area",
|
||||
"justifyMode": "auto",
|
||||
"orientation": "auto",
|
||||
"reduceOptions": {
|
||||
"calcs": ["lastNotNull"],
|
||||
"fields": "",
|
||||
"values": false
|
||||
},
|
||||
"textMode": "auto"
|
||||
},
|
||||
"pluginVersion": "10.0.0",
|
||||
"targets": [
|
||||
{
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "${DS_PROMETHEUS}"
|
||||
},
|
||||
"editorMode": "code",
|
||||
"expr": "wordpress_cron_overdue_total",
|
||||
"instant": true,
|
||||
"legendFormat": "__auto",
|
||||
"refId": "A"
|
||||
}
|
||||
],
|
||||
"title": "Overdue Cron Events",
|
||||
"type": "stat"
|
||||
},
|
||||
{
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "${DS_PROMETHEUS}"
|
||||
},
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"color": {
|
||||
"mode": "thresholds"
|
||||
},
|
||||
"mappings": [],
|
||||
"thresholds": {
|
||||
"mode": "absolute",
|
||||
"steps": [
|
||||
{
|
||||
"color": "blue",
|
||||
"value": null
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"gridPos": {
|
||||
"h": 4,
|
||||
"w": 6,
|
||||
"x": 6,
|
||||
"y": 15
|
||||
},
|
||||
"id": 12,
|
||||
"options": {
|
||||
"colorMode": "value",
|
||||
"graphMode": "none",
|
||||
"justifyMode": "auto",
|
||||
"orientation": "auto",
|
||||
"reduceOptions": {
|
||||
"calcs": ["lastNotNull"],
|
||||
"fields": "",
|
||||
"values": false
|
||||
},
|
||||
"textMode": "auto"
|
||||
},
|
||||
"pluginVersion": "10.0.0",
|
||||
"targets": [
|
||||
{
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "${DS_PROMETHEUS}"
|
||||
},
|
||||
"editorMode": "code",
|
||||
"expr": "sum(wordpress_cron_events_total)",
|
||||
"instant": true,
|
||||
"legendFormat": "__auto",
|
||||
"refId": "A"
|
||||
}
|
||||
],
|
||||
"title": "Total Cron Events",
|
||||
"type": "stat"
|
||||
},
|
||||
{
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "${DS_PROMETHEUS}"
|
||||
},
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"color": {
|
||||
"mode": "thresholds"
|
||||
},
|
||||
"mappings": [],
|
||||
"thresholds": {
|
||||
"mode": "absolute",
|
||||
"steps": [
|
||||
{
|
||||
"color": "blue",
|
||||
"value": null
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"gridPos": {
|
||||
"h": 4,
|
||||
"w": 6,
|
||||
"x": 12,
|
||||
"y": 15
|
||||
},
|
||||
"id": 13,
|
||||
"options": {
|
||||
"colorMode": "value",
|
||||
"graphMode": "none",
|
||||
"justifyMode": "auto",
|
||||
"orientation": "auto",
|
||||
"reduceOptions": {
|
||||
"calcs": ["lastNotNull"],
|
||||
"fields": "",
|
||||
"values": false
|
||||
},
|
||||
"textMode": "auto"
|
||||
},
|
||||
"pluginVersion": "10.0.0",
|
||||
"targets": [
|
||||
{
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "${DS_PROMETHEUS}"
|
||||
},
|
||||
"editorMode": "code",
|
||||
"expr": "wordpress_transients_total{type=\"total\"}",
|
||||
"instant": true,
|
||||
"legendFormat": "__auto",
|
||||
"refId": "A"
|
||||
}
|
||||
],
|
||||
"title": "Total Transients",
|
||||
"type": "stat"
|
||||
},
|
||||
{
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "${DS_PROMETHEUS}"
|
||||
},
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"color": {
|
||||
"mode": "thresholds"
|
||||
},
|
||||
"mappings": [],
|
||||
"thresholds": {
|
||||
"mode": "absolute",
|
||||
"steps": [
|
||||
{
|
||||
"color": "green",
|
||||
"value": null
|
||||
},
|
||||
{
|
||||
"color": "yellow",
|
||||
"value": 50
|
||||
},
|
||||
{
|
||||
"color": "red",
|
||||
"value": 100
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"gridPos": {
|
||||
"h": 4,
|
||||
"w": 6,
|
||||
"x": 18,
|
||||
"y": 15
|
||||
},
|
||||
"id": 14,
|
||||
"options": {
|
||||
"colorMode": "value",
|
||||
"graphMode": "area",
|
||||
"justifyMode": "auto",
|
||||
"orientation": "auto",
|
||||
"reduceOptions": {
|
||||
"calcs": ["lastNotNull"],
|
||||
"fields": "",
|
||||
"values": false
|
||||
},
|
||||
"textMode": "auto"
|
||||
},
|
||||
"pluginVersion": "10.0.0",
|
||||
"targets": [
|
||||
{
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "${DS_PROMETHEUS}"
|
||||
},
|
||||
"editorMode": "code",
|
||||
"expr": "wordpress_transients_total{type=\"expired\"}",
|
||||
"instant": true,
|
||||
"legendFormat": "__auto",
|
||||
"refId": "A"
|
||||
}
|
||||
],
|
||||
"title": "Expired Transients",
|
||||
"type": "stat"
|
||||
},
|
||||
{
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "${DS_PROMETHEUS}"
|
||||
},
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"color": {
|
||||
"mode": "palette-classic"
|
||||
},
|
||||
"custom": {
|
||||
"axisBorderShow": false,
|
||||
"axisCenteredZero": false,
|
||||
"axisColorMode": "text",
|
||||
"axisLabel": "",
|
||||
"axisPlacement": "auto",
|
||||
"barAlignment": 0,
|
||||
"drawStyle": "bars",
|
||||
"fillOpacity": 80,
|
||||
"gradientMode": "none",
|
||||
"hideFrom": {
|
||||
"legend": false,
|
||||
"tooltip": false,
|
||||
"viz": false
|
||||
},
|
||||
"insertNulls": false,
|
||||
"lineInterpolation": "linear",
|
||||
"lineWidth": 1,
|
||||
"pointSize": 5,
|
||||
"scaleDistribution": {
|
||||
"type": "linear"
|
||||
},
|
||||
"showPoints": "auto",
|
||||
"spanNulls": false,
|
||||
"stacking": {
|
||||
"group": "A",
|
||||
"mode": "none"
|
||||
},
|
||||
"thresholdsStyle": {
|
||||
"mode": "off"
|
||||
}
|
||||
},
|
||||
"mappings": [],
|
||||
"thresholds": {
|
||||
"mode": "absolute",
|
||||
"steps": [
|
||||
{
|
||||
"color": "green",
|
||||
"value": null
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"gridPos": {
|
||||
"h": 8,
|
||||
"w": 24,
|
||||
"x": 0,
|
||||
"y": 19
|
||||
},
|
||||
"id": 15,
|
||||
"options": {
|
||||
"legend": {
|
||||
"calcs": [],
|
||||
"displayMode": "list",
|
||||
"placement": "bottom",
|
||||
"showLegend": true
|
||||
},
|
||||
"tooltip": {
|
||||
"mode": "single",
|
||||
"sort": "none"
|
||||
}
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "${DS_PROMETHEUS}"
|
||||
},
|
||||
"editorMode": "code",
|
||||
"expr": "topk(10, wordpress_cron_events_total)",
|
||||
"instant": false,
|
||||
"legendFormat": "{{hook}}",
|
||||
"range": true,
|
||||
"refId": "A"
|
||||
}
|
||||
],
|
||||
"title": "Top 10 Cron Hooks",
|
||||
"type": "timeseries"
|
||||
}
|
||||
],
|
||||
"refresh": "30s",
|
||||
"schemaVersion": 39,
|
||||
"tags": ["wordpress", "prometheus"],
|
||||
"templating": {
|
||||
"list": [
|
||||
{
|
||||
"current": {
|
||||
"selected": false,
|
||||
"text": "Prometheus",
|
||||
"value": "Prometheus"
|
||||
},
|
||||
"hide": 0,
|
||||
"includeAll": false,
|
||||
"label": "Data Source",
|
||||
"multi": false,
|
||||
"name": "DS_PROMETHEUS",
|
||||
"options": [],
|
||||
"query": "prometheus",
|
||||
"queryValue": "",
|
||||
"refresh": 1,
|
||||
"regex": "",
|
||||
"skipUrlSync": false,
|
||||
"type": "datasource"
|
||||
}
|
||||
]
|
||||
},
|
||||
"time": {
|
||||
"from": "now-6h",
|
||||
"to": "now"
|
||||
},
|
||||
"timepicker": {},
|
||||
"timezone": "browser",
|
||||
"title": "WordPress Overview",
|
||||
"uid": "wp-prometheus-overview",
|
||||
"version": 1,
|
||||
"weekStart": ""
|
||||
}
|
||||
1076
assets/dashboards/wordpress-runtime.json
Normal file
1076
assets/dashboards/wordpress-runtime.json
Normal file
File diff suppressed because it is too large
Load Diff
1296
assets/dashboards/wordpress-woocommerce.json
Normal file
1296
assets/dashboards/wordpress-woocommerce.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -7,7 +7,29 @@
|
||||
(function($) {
|
||||
'use strict';
|
||||
|
||||
var importFileContent = null;
|
||||
|
||||
$(document).ready(function() {
|
||||
// License tab handlers.
|
||||
initLicenseHandlers();
|
||||
|
||||
// Custom metrics tab handlers.
|
||||
initCustomMetricsHandlers();
|
||||
|
||||
// Dashboards tab handlers.
|
||||
initDashboardsHandlers();
|
||||
|
||||
// Runtime metrics reset handler.
|
||||
initResetRuntimeHandler();
|
||||
|
||||
// Storage tab handlers.
|
||||
initStorageHandlers();
|
||||
});
|
||||
|
||||
/**
|
||||
* Initialize license tab handlers.
|
||||
*/
|
||||
function initLicenseHandlers() {
|
||||
// Validate license button.
|
||||
$('#wp-prometheus-validate-license').on('click', function(e) {
|
||||
e.preventDefault();
|
||||
@@ -23,78 +45,664 @@
|
||||
// Regenerate token button.
|
||||
$('#wp-prometheus-regenerate-token').on('click', function(e) {
|
||||
e.preventDefault();
|
||||
if (confirm('Are you sure you want to regenerate the auth token? You will need to update your Prometheus configuration.')) {
|
||||
if (confirm(wpPrometheus.confirmRegenerateToken)) {
|
||||
var newToken = generateToken(32);
|
||||
$('#wp_prometheus_auth_token').val(newToken);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform a license action via AJAX.
|
||||
*
|
||||
* @param {string} action AJAX action name.
|
||||
* @param {string} message Loading message.
|
||||
*/
|
||||
function performLicenseAction(action, message) {
|
||||
var $spinner = $('#wp-prometheus-license-spinner');
|
||||
var $message = $('#wp-prometheus-license-message');
|
||||
/**
|
||||
* Initialize custom metrics tab handlers.
|
||||
*/
|
||||
function initCustomMetricsHandlers() {
|
||||
var $formContainer = $('#wp-prometheus-metric-form-container');
|
||||
var $form = $('#wp-prometheus-metric-form');
|
||||
var $showFormBtn = $('#show-metric-form');
|
||||
|
||||
$spinner.addClass('is-active');
|
||||
$message.hide();
|
||||
// Show metric form.
|
||||
$showFormBtn.on('click', function() {
|
||||
resetMetricForm();
|
||||
$formContainer.slideDown();
|
||||
$(this).hide();
|
||||
});
|
||||
|
||||
$.ajax({
|
||||
url: wpPrometheus.ajaxUrl,
|
||||
type: 'POST',
|
||||
data: {
|
||||
action: action,
|
||||
nonce: wpPrometheus.nonce
|
||||
},
|
||||
success: function(response) {
|
||||
$spinner.removeClass('is-active');
|
||||
// Cancel metric form.
|
||||
$('#cancel-metric-form').on('click', function() {
|
||||
$formContainer.slideUp();
|
||||
$showFormBtn.show();
|
||||
// Remove edit parameter from URL.
|
||||
if (window.location.search.indexOf('edit=') > -1) {
|
||||
window.history.pushState({}, '', window.location.pathname + '?page=wp-prometheus&tab=custom');
|
||||
}
|
||||
});
|
||||
|
||||
if (response.success) {
|
||||
$message
|
||||
.removeClass('notice-error')
|
||||
.addClass('notice notice-success')
|
||||
.html('<p>' + response.data.message + '</p>')
|
||||
.show();
|
||||
// Value type toggle.
|
||||
$('input[name="value_type"]').on('change', function() {
|
||||
var valueType = $(this).val();
|
||||
if (valueType === 'option') {
|
||||
$('#option-config-row').show();
|
||||
$('#static-values-row').hide();
|
||||
} else {
|
||||
$('#option-config-row').hide();
|
||||
$('#static-values-row').show();
|
||||
}
|
||||
});
|
||||
|
||||
// Reload page after successful validation/activation.
|
||||
// Add label.
|
||||
$('#add-label').on('click', function() {
|
||||
var $container = $('#metric-labels-container');
|
||||
var labelCount = $container.find('.metric-label-row').length;
|
||||
|
||||
if (labelCount >= 5) {
|
||||
alert('Maximum 5 labels allowed per metric.');
|
||||
return;
|
||||
}
|
||||
|
||||
var $row = $('<div class="metric-label-row">' +
|
||||
'<input type="text" name="labels[]" class="regular-text" placeholder="label_name" pattern="[a-zA-Z_][a-zA-Z0-9_]*">' +
|
||||
'<button type="button" class="button remove-label">×</button>' +
|
||||
'</div>');
|
||||
$container.append($row);
|
||||
updateValueRows();
|
||||
});
|
||||
|
||||
// Remove label.
|
||||
$(document).on('click', '.remove-label', function() {
|
||||
$(this).closest('.metric-label-row').remove();
|
||||
updateValueRows();
|
||||
});
|
||||
|
||||
// Add value row.
|
||||
$('#add-value-row').on('click', function() {
|
||||
var $container = $('#metric-values-container');
|
||||
var rowCount = $container.find('.metric-value-row').length;
|
||||
var labelCount = getLabelCount();
|
||||
|
||||
var $row = $('<div class="metric-value-row"></div>');
|
||||
|
||||
// Add label value inputs.
|
||||
var labels = getLabels();
|
||||
for (var i = 0; i < labelCount; i++) {
|
||||
$row.append('<input type="text" name="label_values[' + rowCount + '][]" class="small-text" placeholder="' + (labels[i] || 'value') + '">');
|
||||
}
|
||||
|
||||
// Add metric value input.
|
||||
$row.append('<input type="number" name="label_values[' + rowCount + '][]" class="small-text" step="any" placeholder="Value">');
|
||||
$row.append('<button type="button" class="button remove-value-row">×</button>');
|
||||
|
||||
$container.append($row);
|
||||
});
|
||||
|
||||
// Remove value row.
|
||||
$(document).on('click', '.remove-value-row', function() {
|
||||
$(this).closest('.metric-value-row').remove();
|
||||
});
|
||||
|
||||
// Submit metric form.
|
||||
$form.on('submit', function(e) {
|
||||
e.preventDefault();
|
||||
saveCustomMetric();
|
||||
});
|
||||
|
||||
// Delete metric.
|
||||
$(document).on('click', '.delete-metric', function() {
|
||||
var id = $(this).data('id');
|
||||
if (confirm(wpPrometheus.confirmDelete)) {
|
||||
deleteCustomMetric(id);
|
||||
}
|
||||
});
|
||||
|
||||
// Export metrics.
|
||||
$('#export-metrics').on('click', function() {
|
||||
exportMetrics();
|
||||
});
|
||||
|
||||
// Import metrics - trigger file input.
|
||||
$('#import-metrics-btn').on('click', function() {
|
||||
$('#import-metrics-file').click();
|
||||
});
|
||||
|
||||
// Import file selected.
|
||||
$('#import-metrics-file').on('change', function(e) {
|
||||
var file = e.target.files[0];
|
||||
if (file) {
|
||||
var reader = new FileReader();
|
||||
reader.onload = function(e) {
|
||||
importFileContent = e.target.result;
|
||||
$('#import-options').slideDown();
|
||||
};
|
||||
reader.readAsText(file);
|
||||
}
|
||||
});
|
||||
|
||||
// Confirm import.
|
||||
$('#confirm-import').on('click', function() {
|
||||
var mode = $('input[name="import_mode"]:checked').val();
|
||||
importMetrics(importFileContent, mode);
|
||||
});
|
||||
|
||||
// Cancel import.
|
||||
$('#cancel-import').on('click', function() {
|
||||
$('#import-options').slideUp();
|
||||
$('#import-metrics-file').val('');
|
||||
importFileContent = null;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize dashboards tab handlers.
|
||||
*/
|
||||
function initDashboardsHandlers() {
|
||||
$(document).on('click', '.download-dashboard', function() {
|
||||
var slug = $(this).data('slug');
|
||||
downloadDashboard(slug);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize reset runtime metrics handler.
|
||||
*/
|
||||
function initResetRuntimeHandler() {
|
||||
$('#wp-prometheus-reset-runtime').on('click', function() {
|
||||
if (confirm(wpPrometheus.confirmReset)) {
|
||||
resetRuntimeMetrics();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform a license action via AJAX.
|
||||
*
|
||||
* @param {string} action AJAX action name.
|
||||
* @param {string} message Loading message.
|
||||
*/
|
||||
function performLicenseAction(action, message) {
|
||||
var $spinner = $('#wp-prometheus-license-spinner');
|
||||
var $message = $('#wp-prometheus-license-message');
|
||||
|
||||
$spinner.addClass('is-active');
|
||||
$message.hide();
|
||||
|
||||
$.ajax({
|
||||
url: wpPrometheus.ajaxUrl,
|
||||
type: 'POST',
|
||||
data: {
|
||||
action: action,
|
||||
nonce: wpPrometheus.nonce
|
||||
},
|
||||
success: function(response) {
|
||||
$spinner.removeClass('is-active');
|
||||
|
||||
if (response.success) {
|
||||
showNotice($message, response.data.message, 'success');
|
||||
|
||||
// Reload page after successful validation/activation.
|
||||
setTimeout(function() {
|
||||
location.reload();
|
||||
}, 1500);
|
||||
} else {
|
||||
showNotice($message, response.data.message || 'An error occurred.', 'error');
|
||||
}
|
||||
},
|
||||
error: function() {
|
||||
$spinner.removeClass('is-active');
|
||||
showNotice($message, 'Connection error. Please try again.', 'error');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Save custom metric via AJAX.
|
||||
*/
|
||||
function saveCustomMetric() {
|
||||
var $spinner = $('#wp-prometheus-metric-spinner');
|
||||
var $message = $('#wp-prometheus-metric-message');
|
||||
var $form = $('#wp-prometheus-metric-form');
|
||||
|
||||
$spinner.addClass('is-active');
|
||||
$message.hide();
|
||||
|
||||
var formData = $form.serialize();
|
||||
formData += '&action=wp_prometheus_save_custom_metric';
|
||||
formData += '&nonce=' + wpPrometheus.customMetricNonce;
|
||||
|
||||
$.ajax({
|
||||
url: wpPrometheus.ajaxUrl,
|
||||
type: 'POST',
|
||||
data: formData,
|
||||
success: function(response) {
|
||||
$spinner.removeClass('is-active');
|
||||
|
||||
if (response.success) {
|
||||
showNotice($message, response.data.message, 'success');
|
||||
|
||||
setTimeout(function() {
|
||||
window.location.href = window.location.pathname + '?page=wp-prometheus&tab=custom';
|
||||
}, 1000);
|
||||
} else {
|
||||
showNotice($message, response.data.message || 'An error occurred.', 'error');
|
||||
}
|
||||
},
|
||||
error: function() {
|
||||
$spinner.removeClass('is-active');
|
||||
showNotice($message, 'Connection error. Please try again.', 'error');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete custom metric via AJAX.
|
||||
*
|
||||
* @param {string} id Metric ID.
|
||||
*/
|
||||
function deleteCustomMetric(id) {
|
||||
$.ajax({
|
||||
url: wpPrometheus.ajaxUrl,
|
||||
type: 'POST',
|
||||
data: {
|
||||
action: 'wp_prometheus_delete_custom_metric',
|
||||
nonce: wpPrometheus.customMetricNonce,
|
||||
id: id
|
||||
},
|
||||
success: function(response) {
|
||||
if (response.success) {
|
||||
$('tr[data-metric-id="' + id + '"]').fadeOut(function() {
|
||||
$(this).remove();
|
||||
// Check if table is empty.
|
||||
if ($('.wp-prometheus-custom-metrics tbody tr').length === 0) {
|
||||
location.reload();
|
||||
}
|
||||
});
|
||||
} else {
|
||||
alert(response.data.message || 'An error occurred.');
|
||||
}
|
||||
},
|
||||
error: function() {
|
||||
alert('Connection error. Please try again.');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Export metrics via AJAX.
|
||||
*/
|
||||
function exportMetrics() {
|
||||
$.ajax({
|
||||
url: wpPrometheus.ajaxUrl,
|
||||
type: 'POST',
|
||||
data: {
|
||||
action: 'wp_prometheus_export_metrics',
|
||||
nonce: wpPrometheus.exportNonce
|
||||
},
|
||||
success: function(response) {
|
||||
if (response.success) {
|
||||
downloadFile(response.data.json, response.data.filename, 'application/json');
|
||||
} else {
|
||||
alert(response.data.message || 'An error occurred.');
|
||||
}
|
||||
},
|
||||
error: function() {
|
||||
alert('Connection error. Please try again.');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Import metrics via AJAX.
|
||||
*
|
||||
* @param {string} json JSON content.
|
||||
* @param {string} mode Import mode.
|
||||
*/
|
||||
function importMetrics(json, mode) {
|
||||
var $spinner = $('#wp-prometheus-import-spinner');
|
||||
var $message = $('#wp-prometheus-import-message');
|
||||
|
||||
$spinner.addClass('is-active');
|
||||
$message.hide();
|
||||
|
||||
$.ajax({
|
||||
url: wpPrometheus.ajaxUrl,
|
||||
type: 'POST',
|
||||
data: {
|
||||
action: 'wp_prometheus_import_metrics',
|
||||
nonce: wpPrometheus.importNonce,
|
||||
json: json,
|
||||
mode: mode
|
||||
},
|
||||
success: function(response) {
|
||||
$spinner.removeClass('is-active');
|
||||
|
||||
if (response.success) {
|
||||
showNotice($message, response.data.message, 'success');
|
||||
|
||||
$('#import-options').slideUp();
|
||||
$('#import-metrics-file').val('');
|
||||
importFileContent = null;
|
||||
|
||||
setTimeout(function() {
|
||||
location.reload();
|
||||
}, 1500);
|
||||
} else {
|
||||
showNotice($message, response.data.message || 'An error occurred.', 'error');
|
||||
}
|
||||
},
|
||||
error: function() {
|
||||
$spinner.removeClass('is-active');
|
||||
showNotice($message, 'Connection error. Please try again.', 'error');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Download dashboard via AJAX.
|
||||
*
|
||||
* @param {string} slug Dashboard slug.
|
||||
*/
|
||||
function downloadDashboard(slug) {
|
||||
$.ajax({
|
||||
url: wpPrometheus.ajaxUrl,
|
||||
type: 'POST',
|
||||
data: {
|
||||
action: 'wp_prometheus_download_dashboard',
|
||||
nonce: wpPrometheus.dashboardNonce,
|
||||
slug: slug
|
||||
},
|
||||
success: function(response) {
|
||||
if (response.success) {
|
||||
downloadFile(response.data.json, response.data.filename, 'application/json');
|
||||
} else {
|
||||
alert(response.data.message || 'An error occurred.');
|
||||
}
|
||||
},
|
||||
error: function() {
|
||||
alert('Connection error. Please try again.');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset runtime metrics via AJAX.
|
||||
*/
|
||||
function resetRuntimeMetrics() {
|
||||
var $spinner = $('#wp-prometheus-reset-spinner');
|
||||
var $message = $('#wp-prometheus-reset-message');
|
||||
|
||||
$spinner.addClass('is-active');
|
||||
$message.hide();
|
||||
|
||||
$.ajax({
|
||||
url: wpPrometheus.ajaxUrl,
|
||||
type: 'POST',
|
||||
data: {
|
||||
action: 'wp_prometheus_reset_runtime_metrics',
|
||||
nonce: wpPrometheus.resetRuntimeNonce
|
||||
},
|
||||
success: function(response) {
|
||||
$spinner.removeClass('is-active');
|
||||
|
||||
if (response.success) {
|
||||
showNotice($message, response.data.message, 'success');
|
||||
} else {
|
||||
showNotice($message, response.data.message || 'An error occurred.', 'error');
|
||||
}
|
||||
},
|
||||
error: function() {
|
||||
$spinner.removeClass('is-active');
|
||||
showNotice($message, 'Connection error. Please try again.', 'error');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset the metric form to default state.
|
||||
*/
|
||||
function resetMetricForm() {
|
||||
var $form = $('#wp-prometheus-metric-form');
|
||||
$form[0].reset();
|
||||
$('#metric-id').val('');
|
||||
$('#wp-prometheus-form-title').text('Add New Metric');
|
||||
$('#option-config-row').hide();
|
||||
$('#static-values-row').show();
|
||||
$('#wp-prometheus-metric-message').hide();
|
||||
|
||||
// Reset labels to single empty row.
|
||||
$('#metric-labels-container').html(
|
||||
'<div class="metric-label-row">' +
|
||||
'<input type="text" name="labels[]" class="regular-text" placeholder="label_name" pattern="[a-zA-Z_][a-zA-Z0-9_]*">' +
|
||||
'<button type="button" class="button remove-label">×</button>' +
|
||||
'</div>'
|
||||
);
|
||||
|
||||
// Reset values to single empty row.
|
||||
$('#metric-values-container').html(
|
||||
'<div class="metric-value-row">' +
|
||||
'<input type="number" name="label_values[0][]" class="small-text" step="any" placeholder="Value">' +
|
||||
'<button type="button" class="button remove-value-row">×</button>' +
|
||||
'</div>'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update value rows when labels change.
|
||||
*/
|
||||
function updateValueRows() {
|
||||
var labels = getLabels();
|
||||
var labelCount = labels.length;
|
||||
|
||||
$('#metric-values-container .metric-value-row').each(function(rowIndex) {
|
||||
var $row = $(this);
|
||||
var inputs = $row.find('input').toArray();
|
||||
var currentValues = inputs.map(function(input) { return input.value; });
|
||||
|
||||
// Remove all inputs except the value and button.
|
||||
$row.find('input').remove();
|
||||
|
||||
// Re-add label inputs using safe DOM construction.
|
||||
for (var i = 0; i < labelCount; i++) {
|
||||
var val = currentValues[i] || '';
|
||||
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 using safe DOM construction.
|
||||
var metricVal = currentValues[currentValues.length - 1] || '';
|
||||
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);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current label names.
|
||||
*
|
||||
* @return {string[]} Array of label names.
|
||||
*/
|
||||
function getLabels() {
|
||||
var labels = [];
|
||||
$('#metric-labels-container input[name="labels[]"]').each(function() {
|
||||
var val = $(this).val().trim();
|
||||
if (val) {
|
||||
labels.push(val);
|
||||
}
|
||||
});
|
||||
return labels;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current label count.
|
||||
*
|
||||
* @return {number} Number of labels.
|
||||
*/
|
||||
function getLabelCount() {
|
||||
return getLabels().length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a cryptographically secure random token.
|
||||
*
|
||||
* @param {number} length Token length.
|
||||
* @return {string} Generated token.
|
||||
*/
|
||||
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(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.
|
||||
*
|
||||
* @param {string} content File content.
|
||||
* @param {string} filename Filename.
|
||||
* @param {string} type MIME type.
|
||||
*/
|
||||
function downloadFile(content, filename, type) {
|
||||
var blob = new Blob([content], { type: type });
|
||||
var url = URL.createObjectURL(blob);
|
||||
var a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize storage tab handlers.
|
||||
*/
|
||||
function initStorageHandlers() {
|
||||
var $form = $('#wp-prometheus-storage-form');
|
||||
var $adapterSelect = $('#storage-adapter');
|
||||
|
||||
// Show/hide adapter-specific config.
|
||||
$adapterSelect.on('change', function() {
|
||||
var adapter = $(this).val();
|
||||
$('#redis-config').toggle(adapter === 'redis');
|
||||
$('#apcu-config').toggle(adapter === 'apcu');
|
||||
});
|
||||
|
||||
// Save storage settings.
|
||||
$form.on('submit', function(e) {
|
||||
e.preventDefault();
|
||||
saveStorageSettings();
|
||||
});
|
||||
|
||||
// Test storage connection.
|
||||
$('#test-storage').on('click', function() {
|
||||
testStorageConnection();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Save storage settings via AJAX.
|
||||
*/
|
||||
function saveStorageSettings() {
|
||||
var $spinner = $('#wp-prometheus-storage-spinner');
|
||||
var $message = $('#wp-prometheus-storage-message');
|
||||
var $form = $('#wp-prometheus-storage-form');
|
||||
|
||||
$spinner.addClass('is-active');
|
||||
$message.hide();
|
||||
|
||||
var formData = $form.serialize();
|
||||
formData += '&action=wp_prometheus_save_storage';
|
||||
formData += '&nonce=' + wpPrometheus.storageNonce;
|
||||
|
||||
$.ajax({
|
||||
url: wpPrometheus.ajaxUrl,
|
||||
type: 'POST',
|
||||
data: formData,
|
||||
success: function(response) {
|
||||
$spinner.removeClass('is-active');
|
||||
|
||||
if (response.success) {
|
||||
var type = response.data.warning ? 'warning' : 'success';
|
||||
showNotice($message, response.data.message, type);
|
||||
|
||||
if (!response.data.warning) {
|
||||
setTimeout(function() {
|
||||
location.reload();
|
||||
}, 1500);
|
||||
} else {
|
||||
$message
|
||||
.removeClass('notice-success')
|
||||
.addClass('notice notice-error')
|
||||
.html('<p>' + (response.data.message || 'An error occurred.') + '</p>')
|
||||
.show();
|
||||
}
|
||||
},
|
||||
error: function() {
|
||||
$spinner.removeClass('is-active');
|
||||
$message
|
||||
.removeClass('notice-success')
|
||||
.addClass('notice notice-error')
|
||||
.html('<p>Connection error. Please try again.</p>')
|
||||
.show();
|
||||
} else {
|
||||
showNotice($message, response.data.message || 'An error occurred.', 'error');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a random token.
|
||||
*
|
||||
* @param {number} length Token length.
|
||||
* @return {string} Generated token.
|
||||
*/
|
||||
function generateToken(length) {
|
||||
var charset = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
|
||||
var token = '';
|
||||
for (var i = 0; i < length; i++) {
|
||||
token += charset.charAt(Math.floor(Math.random() * charset.length));
|
||||
},
|
||||
error: function() {
|
||||
$spinner.removeClass('is-active');
|
||||
showNotice($message, 'Connection error. Please try again.', 'error');
|
||||
}
|
||||
return token;
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Test storage connection via AJAX.
|
||||
*/
|
||||
function testStorageConnection() {
|
||||
var $spinner = $('#wp-prometheus-storage-spinner');
|
||||
var $message = $('#wp-prometheus-storage-message');
|
||||
var $form = $('#wp-prometheus-storage-form');
|
||||
|
||||
$spinner.addClass('is-active');
|
||||
$message.hide();
|
||||
|
||||
var formData = $form.serialize();
|
||||
formData += '&action=wp_prometheus_test_storage';
|
||||
formData += '&nonce=' + wpPrometheus.storageNonce;
|
||||
|
||||
$.ajax({
|
||||
url: wpPrometheus.ajaxUrl,
|
||||
type: 'POST',
|
||||
data: formData,
|
||||
success: function(response) {
|
||||
$spinner.removeClass('is-active');
|
||||
|
||||
if (response.success) {
|
||||
showNotice($message, response.data.message, 'success');
|
||||
} else {
|
||||
showNotice($message, response.data.message || 'Connection test failed.', 'error');
|
||||
}
|
||||
},
|
||||
error: function() {
|
||||
$spinner.removeClass('is-active');
|
||||
showNotice($message, 'Connection error. Please try again.', 'error');
|
||||
}
|
||||
});
|
||||
}
|
||||
})(jQuery);
|
||||
|
||||
@@ -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
209
composer.lock
generated
@@ -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.
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
25
phpunit.xml
Normal file
25
phpunit.xml
Normal 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>
|
||||
471
src/Admin/DashboardProvider.php
Normal file
471
src/Admin/DashboardProvider.php
Normal file
@@ -0,0 +1,471 @@
|
||||
<?php
|
||||
/**
|
||||
* Dashboard provider class.
|
||||
*
|
||||
* @package WP_Prometheus
|
||||
*/
|
||||
|
||||
namespace Magdev\WpPrometheus\Admin;
|
||||
|
||||
// Prevent direct file access.
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* DashboardProvider class.
|
||||
*
|
||||
* Provides Grafana dashboard templates for download.
|
||||
* Supports both built-in dashboards and third-party registrations.
|
||||
*/
|
||||
class DashboardProvider {
|
||||
|
||||
/**
|
||||
* Dashboard directory path for built-in dashboards.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
private string $dashboard_dir;
|
||||
|
||||
/**
|
||||
* Built-in dashboard definitions.
|
||||
*
|
||||
* @var 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/';
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
public function get_available(): array {
|
||||
// Fire registration hook first (only once).
|
||||
$this->fire_registration_hook();
|
||||
|
||||
$available = array();
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get dashboard content by slug.
|
||||
*
|
||||
* @param string $slug Dashboard slug.
|
||||
* @return string|null JSON content or null if not found.
|
||||
*/
|
||||
public function get_dashboard( string $slug ): ?string {
|
||||
// Fire registration hook first.
|
||||
$this->fire_registration_hook();
|
||||
|
||||
// Validate slug.
|
||||
$slug = sanitize_file_name( $slug );
|
||||
|
||||
// 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 ) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if ( ! file_exists( $file_path ) || ! is_readable( $file_path ) ) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents
|
||||
$content = file_get_contents( $file_path );
|
||||
|
||||
return false === $content ? null : $content;
|
||||
}
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get dashboard metadata by slug.
|
||||
*
|
||||
* @param string $slug Dashboard slug.
|
||||
* @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 );
|
||||
|
||||
$builtin = $this->get_builtin_dashboards();
|
||||
if ( isset( $builtin[ $slug ] ) ) {
|
||||
return $builtin[ $slug ];
|
||||
}
|
||||
|
||||
if ( isset( $this->registered_dashboards[ $slug ] ) ) {
|
||||
return $this->registered_dashboards[ $slug ];
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get filename for download.
|
||||
*
|
||||
* @param string $slug Dashboard slug.
|
||||
* @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 );
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -45,7 +45,9 @@ class MetricsEndpoint {
|
||||
*/
|
||||
private function init_hooks(): void {
|
||||
add_action( 'init', array( $this, 'register_endpoint' ) );
|
||||
add_action( 'template_redirect', array( $this, 'handle_request' ) );
|
||||
// Use parse_request instead of template_redirect to handle the request early,
|
||||
// before themes and other plugins (like Twig-based ones) can interfere.
|
||||
add_action( 'parse_request', array( $this, 'handle_request' ) );
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -66,10 +68,13 @@ class MetricsEndpoint {
|
||||
/**
|
||||
* Handle the metrics endpoint request.
|
||||
*
|
||||
* Called during parse_request to intercept before themes/plugins load.
|
||||
*
|
||||
* @param \WP $wp WordPress environment instance.
|
||||
* @return void
|
||||
*/
|
||||
public function handle_request(): void {
|
||||
if ( ! get_query_var( 'wp_prometheus_metrics' ) ) {
|
||||
public function handle_request( \WP $wp ): void {
|
||||
if ( empty( $wp->query_vars['wp_prometheus_metrics'] ) ) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -97,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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -64,6 +64,15 @@ final class Installer {
|
||||
'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 ( $options as $option ) {
|
||||
|
||||
@@ -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).
|
||||
*/
|
||||
@@ -302,37 +312,146 @@ final class Manager {
|
||||
* @return bool
|
||||
*/
|
||||
public static function is_license_valid(): bool {
|
||||
// Bypass license check on localhost for development.
|
||||
if ( self::is_localhost() ) {
|
||||
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.
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
public static function is_localhost(): bool {
|
||||
$host = wp_parse_url( home_url(), PHP_URL_HOST );
|
||||
|
||||
$localhost_patterns = array(
|
||||
'localhost',
|
||||
'127.0.0.1',
|
||||
'::1',
|
||||
);
|
||||
|
||||
// Check exact matches.
|
||||
if ( in_array( $host, $localhost_patterns, true ) ) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check .localhost TLD (e.g., mysite.localhost).
|
||||
if ( str_ends_with( $host, '.localhost' ) ) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check .local TLD (common for local development).
|
||||
if ( str_ends_with( $host, '.local' ) ) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
@@ -396,9 +515,16 @@ final class Manager {
|
||||
* @return void
|
||||
*/
|
||||
private function update_cached_status( string $status, array $data = array() ): void {
|
||||
$previous_status = get_option( self::OPTION_LICENSE_STATUS, 'unchecked' );
|
||||
|
||||
update_option( self::OPTION_LICENSE_STATUS, $status );
|
||||
update_option( self::OPTION_LICENSE_DATA, $data );
|
||||
update_option( self::OPTION_LAST_CHECK, time() );
|
||||
|
||||
// Flush rewrite rules when license becomes valid to register the /metrics endpoint.
|
||||
if ( 'valid' === $status && 'valid' !== $previous_status ) {
|
||||
flush_rewrite_rules();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -465,6 +591,9 @@ final class Manager {
|
||||
update_option( self::OPTION_LICENSE_DATA, array() );
|
||||
delete_transient( self::TRANSIENT_LICENSE_CHECK );
|
||||
|
||||
// Flush rewrite rules to remove the /metrics endpoint.
|
||||
flush_rewrite_rules();
|
||||
|
||||
wp_send_json_success( array(
|
||||
'success' => true,
|
||||
'message' => __( 'License deactivated.', 'wp-prometheus' ),
|
||||
|
||||
@@ -8,8 +8,9 @@
|
||||
namespace Magdev\WpPrometheus\Metrics;
|
||||
|
||||
use Prometheus\CollectorRegistry;
|
||||
use Prometheus\Storage\InMemory;
|
||||
use Prometheus\RenderTextFormat;
|
||||
use Magdev\WpPrometheus\Metrics\CustomMetricBuilder;
|
||||
use Magdev\WpPrometheus\Metrics\StorageFactory;
|
||||
|
||||
// Prevent direct file access.
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
@@ -41,7 +42,7 @@ class Collector {
|
||||
* Constructor.
|
||||
*/
|
||||
public function __construct() {
|
||||
$this->registry = new CollectorRegistry( new InMemory() );
|
||||
$this->registry = new CollectorRegistry( StorageFactory::get_adapter() );
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -95,15 +96,87 @@ class Collector {
|
||||
$this->collect_plugins_total();
|
||||
}
|
||||
|
||||
// Collect cron metrics.
|
||||
if ( in_array( 'wordpress_cron_events_total', $enabled_metrics, true ) ) {
|
||||
$this->collect_cron_metrics();
|
||||
}
|
||||
|
||||
// Collect transient metrics.
|
||||
if ( in_array( 'wordpress_transients_total', $enabled_metrics, true ) ) {
|
||||
$this->collect_transient_metrics();
|
||||
}
|
||||
|
||||
// Collect WooCommerce metrics (if WooCommerce is active).
|
||||
if ( $this->is_woocommerce_active() ) {
|
||||
$this->collect_woocommerce_metrics( $enabled_metrics );
|
||||
}
|
||||
|
||||
// Collect runtime metrics (HTTP requests, DB queries).
|
||||
$this->collect_runtime_metrics( $enabled_metrics );
|
||||
|
||||
// Collect custom user-defined metrics.
|
||||
$custom_builder = new CustomMetricBuilder();
|
||||
$custom_builder->register_with_collector( $this );
|
||||
|
||||
/**
|
||||
* Fires after default metrics are collected.
|
||||
*
|
||||
* In isolated mode, skip custom hooks to avoid any potential issues.
|
||||
* In safe mode (default), fire hooks with protection against recursion.
|
||||
*
|
||||
* @param Collector $collector The metrics collector instance.
|
||||
*/
|
||||
do_action( 'wp_prometheus_collect_metrics', $this );
|
||||
if ( defined( 'WP_PROMETHEUS_ISOLATED_MODE' ) && WP_PROMETHEUS_ISOLATED_MODE ) {
|
||||
// Isolated mode: skip all third-party hooks for maximum safety.
|
||||
return;
|
||||
}
|
||||
|
||||
// Safe mode: fire custom hooks with protection.
|
||||
$this->fire_custom_metrics_hook();
|
||||
}
|
||||
|
||||
/**
|
||||
* Fire custom metrics hook with protection against recursion.
|
||||
*
|
||||
* Removes potentially problematic filters, uses output buffering,
|
||||
* and catches any errors from third-party plugins.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
private function fire_custom_metrics_hook(): void {
|
||||
// Remove content filters again (in case any plugin re-added them).
|
||||
if ( function_exists( 'wp_prometheus_remove_content_filters' ) ) {
|
||||
wp_prometheus_remove_content_filters();
|
||||
} else {
|
||||
// Fallback if function doesn't exist.
|
||||
remove_all_filters( 'the_content' );
|
||||
remove_all_filters( 'the_excerpt' );
|
||||
remove_all_filters( 'get_the_excerpt' );
|
||||
remove_all_filters( 'the_title' );
|
||||
}
|
||||
|
||||
// Use output buffering to prevent any accidental output from plugins.
|
||||
ob_start();
|
||||
|
||||
try {
|
||||
/**
|
||||
* Fires after default metrics are collected.
|
||||
*
|
||||
* Third-party plugins can use this hook to add custom metrics.
|
||||
*
|
||||
* @param Collector $collector The metrics collector instance.
|
||||
*/
|
||||
do_action( 'wp_prometheus_collect_metrics', $this );
|
||||
} catch ( \Throwable $e ) {
|
||||
// Log the error but don't let it break metrics output.
|
||||
if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) {
|
||||
// phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
|
||||
error_log( 'WP Prometheus: Error in custom metrics hook: ' . $e->getMessage() );
|
||||
}
|
||||
}
|
||||
|
||||
// Discard any output from plugins.
|
||||
ob_end_clean();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -236,6 +309,380 @@ class Collector {
|
||||
$gauge->set( count( $all_plugins ) - count( $active_plugins ), array( 'inactive' ) );
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect cron metrics.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
private function collect_cron_metrics(): void {
|
||||
$cron_array = _get_cron_array();
|
||||
|
||||
if ( ! is_array( $cron_array ) ) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Events total gauge.
|
||||
$events_gauge = $this->registry->getOrRegisterGauge(
|
||||
$this->namespace,
|
||||
'cron_events_total',
|
||||
'Total number of scheduled cron events',
|
||||
array( 'hook' )
|
||||
);
|
||||
|
||||
// Count events by hook.
|
||||
$hook_counts = array();
|
||||
$total_events = 0;
|
||||
$overdue_count = 0;
|
||||
$current_time = time();
|
||||
$next_run = PHP_INT_MAX;
|
||||
|
||||
foreach ( $cron_array as $timestamp => $cron ) {
|
||||
if ( $timestamp < $next_run ) {
|
||||
$next_run = $timestamp;
|
||||
}
|
||||
|
||||
foreach ( $cron as $hook => $events ) {
|
||||
$event_count = count( $events );
|
||||
$total_events += $event_count;
|
||||
|
||||
if ( ! isset( $hook_counts[ $hook ] ) ) {
|
||||
$hook_counts[ $hook ] = 0;
|
||||
}
|
||||
$hook_counts[ $hook ] += $event_count;
|
||||
|
||||
// Check if overdue.
|
||||
if ( $timestamp < $current_time ) {
|
||||
$overdue_count += $event_count;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Set events by hook (limit to top 20 to avoid cardinality explosion).
|
||||
arsort( $hook_counts );
|
||||
$hook_counts = array_slice( $hook_counts, 0, 20, true );
|
||||
foreach ( $hook_counts as $hook => $count ) {
|
||||
$events_gauge->set( $count, array( $hook ) );
|
||||
}
|
||||
|
||||
// Overdue events gauge.
|
||||
$overdue_gauge = $this->registry->getOrRegisterGauge(
|
||||
$this->namespace,
|
||||
'cron_overdue_total',
|
||||
'Number of overdue cron events',
|
||||
array()
|
||||
);
|
||||
$overdue_gauge->set( $overdue_count, array() );
|
||||
|
||||
// Next run timestamp.
|
||||
$next_run_gauge = $this->registry->getOrRegisterGauge(
|
||||
$this->namespace,
|
||||
'cron_next_run_timestamp',
|
||||
'Unix timestamp of next scheduled cron event',
|
||||
array()
|
||||
);
|
||||
if ( $next_run !== PHP_INT_MAX ) {
|
||||
$next_run_gauge->set( $next_run, array() );
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect transient metrics.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
private function collect_transient_metrics(): void {
|
||||
global $wpdb;
|
||||
|
||||
// Count all transient types in a single query.
|
||||
// phpcs:ignore WordPress.DB.DirectDatabaseQuery
|
||||
$counts = $wpdb->get_row(
|
||||
$wpdb->prepare(
|
||||
"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,
|
||||
'transients_total',
|
||||
'Total number of transients in database',
|
||||
array( 'type' )
|
||||
);
|
||||
|
||||
$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() ) {
|
||||
// phpcs:ignore WordPress.DB.DirectDatabaseQuery
|
||||
$site_transient_count = $wpdb->get_var(
|
||||
"SELECT COUNT(*) FROM {$wpdb->sitemeta} WHERE meta_key LIKE '_site_transient_%' AND meta_key NOT LIKE '_site_transient_timeout_%'"
|
||||
);
|
||||
|
||||
$transients_gauge->set( (int) $site_transient_count, array( 'site_transients' ) );
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if WooCommerce is active.
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
private function is_woocommerce_active(): bool {
|
||||
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.
|
||||
*
|
||||
* @param array $enabled_metrics List of enabled metrics.
|
||||
* @return void
|
||||
*/
|
||||
private function collect_woocommerce_metrics( array $enabled_metrics ): void {
|
||||
// Products total.
|
||||
if ( in_array( 'wordpress_woocommerce_products_total', $enabled_metrics, true ) ) {
|
||||
$this->collect_woocommerce_products();
|
||||
}
|
||||
|
||||
// Orders total.
|
||||
if ( in_array( 'wordpress_woocommerce_orders_total', $enabled_metrics, true ) ) {
|
||||
$this->collect_woocommerce_orders();
|
||||
}
|
||||
|
||||
// Revenue.
|
||||
if ( in_array( 'wordpress_woocommerce_revenue_total', $enabled_metrics, true ) ) {
|
||||
$this->collect_woocommerce_revenue();
|
||||
}
|
||||
|
||||
// Customers.
|
||||
if ( in_array( 'wordpress_woocommerce_customers_total', $enabled_metrics, true ) ) {
|
||||
$this->collect_woocommerce_customers();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect WooCommerce products metrics.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
private function collect_woocommerce_products(): void {
|
||||
$gauge = $this->registry->getOrRegisterGauge(
|
||||
$this->namespace,
|
||||
'woocommerce_products_total',
|
||||
'Total number of WooCommerce products by status and type',
|
||||
array( 'status', 'type' )
|
||||
);
|
||||
|
||||
// Get product counts by status.
|
||||
$product_counts = wp_count_posts( 'product' );
|
||||
$product_types = wc_get_product_types();
|
||||
|
||||
foreach ( get_object_vars( $product_counts ) as $status => $count ) {
|
||||
if ( (int) $count > 0 ) {
|
||||
$gauge->set( (int) $count, array( $status, 'all' ) );
|
||||
}
|
||||
}
|
||||
|
||||
// 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,
|
||||
'return' => 'ids',
|
||||
'paginate' => true,
|
||||
);
|
||||
$result = wc_get_products( $args );
|
||||
$gauge->set( $result->total, array( 'publish', $type ) );
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect WooCommerce orders metrics.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
private function collect_woocommerce_orders(): void {
|
||||
$gauge = $this->registry->getOrRegisterGauge(
|
||||
$this->namespace,
|
||||
'woocommerce_orders_total',
|
||||
'Total number of WooCommerce orders by status',
|
||||
array( 'status' )
|
||||
);
|
||||
|
||||
// Get all registered order statuses and count each.
|
||||
$statuses = wc_get_order_statuses();
|
||||
|
||||
foreach ( array_keys( $statuses ) as $status ) {
|
||||
// Remove 'wc-' prefix for the label.
|
||||
$status_label = str_replace( 'wc-', '', $status );
|
||||
$count = wc_orders_count( $status );
|
||||
$gauge->set( (int) $count, array( $status_label ) );
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect WooCommerce revenue metrics.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
private function collect_woocommerce_revenue(): void {
|
||||
global $wpdb;
|
||||
|
||||
$gauge = $this->registry->getOrRegisterGauge(
|
||||
$this->namespace,
|
||||
'woocommerce_revenue_total',
|
||||
'Total WooCommerce revenue',
|
||||
array( 'period', 'currency' )
|
||||
);
|
||||
|
||||
$currency = get_woocommerce_currency();
|
||||
|
||||
// Check if HPOS (High-Performance Order Storage) is enabled.
|
||||
$hpos_enabled = $this->is_hpos_enabled();
|
||||
|
||||
if ( $hpos_enabled ) {
|
||||
$orders_table = $wpdb->prefix . 'wc_orders';
|
||||
|
||||
// Total revenue (all time) - completed and processing orders.
|
||||
// phpcs:ignore WordPress.DB.DirectDatabaseQuery
|
||||
$total_revenue = $wpdb->get_var(
|
||||
"SELECT SUM(total_amount) FROM {$orders_table} WHERE status IN ('wc-completed', 'wc-processing')"
|
||||
);
|
||||
|
||||
// Today's revenue.
|
||||
// phpcs:ignore WordPress.DB.DirectDatabaseQuery
|
||||
$today_revenue = $wpdb->get_var(
|
||||
$wpdb->prepare(
|
||||
"SELECT SUM(total_amount) FROM {$orders_table} WHERE status IN ('wc-completed', 'wc-processing') AND DATE(date_created_gmt) = %s",
|
||||
gmdate( 'Y-m-d' )
|
||||
)
|
||||
);
|
||||
|
||||
// This month's revenue.
|
||||
// phpcs:ignore WordPress.DB.DirectDatabaseQuery
|
||||
$month_revenue = $wpdb->get_var(
|
||||
$wpdb->prepare(
|
||||
"SELECT SUM(total_amount) FROM {$orders_table} WHERE status IN ('wc-completed', 'wc-processing') AND YEAR(date_created_gmt) = %d AND MONTH(date_created_gmt) = %d",
|
||||
gmdate( 'Y' ),
|
||||
gmdate( 'm' )
|
||||
)
|
||||
);
|
||||
} else {
|
||||
// Legacy post-based orders.
|
||||
// phpcs:ignore WordPress.DB.DirectDatabaseQuery
|
||||
$total_revenue = $wpdb->get_var(
|
||||
"SELECT SUM(meta_value) FROM {$wpdb->postmeta} pm
|
||||
JOIN {$wpdb->posts} p ON p.ID = pm.post_id
|
||||
WHERE pm.meta_key = '_order_total'
|
||||
AND p.post_type = 'shop_order'
|
||||
AND p.post_status IN ('wc-completed', 'wc-processing')"
|
||||
);
|
||||
|
||||
// Today's revenue.
|
||||
// phpcs:ignore WordPress.DB.DirectDatabaseQuery
|
||||
$today_revenue = $wpdb->get_var(
|
||||
$wpdb->prepare(
|
||||
"SELECT SUM(meta_value) FROM {$wpdb->postmeta} pm
|
||||
JOIN {$wpdb->posts} p ON p.ID = pm.post_id
|
||||
WHERE pm.meta_key = '_order_total'
|
||||
AND p.post_type = 'shop_order'
|
||||
AND p.post_status IN ('wc-completed', 'wc-processing')
|
||||
AND DATE(p.post_date_gmt) = %s",
|
||||
gmdate( 'Y-m-d' )
|
||||
)
|
||||
);
|
||||
|
||||
// This month's revenue.
|
||||
// phpcs:ignore WordPress.DB.DirectDatabaseQuery
|
||||
$month_revenue = $wpdb->get_var(
|
||||
$wpdb->prepare(
|
||||
"SELECT SUM(meta_value) FROM {$wpdb->postmeta} pm
|
||||
JOIN {$wpdb->posts} p ON p.ID = pm.post_id
|
||||
WHERE pm.meta_key = '_order_total'
|
||||
AND p.post_type = 'shop_order'
|
||||
AND p.post_status IN ('wc-completed', 'wc-processing')
|
||||
AND YEAR(p.post_date_gmt) = %d
|
||||
AND MONTH(p.post_date_gmt) = %d",
|
||||
gmdate( 'Y' ),
|
||||
gmdate( 'm' )
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
$gauge->set( (float) ( $total_revenue ?? 0 ), array( 'all_time', $currency ) );
|
||||
$gauge->set( (float) ( $today_revenue ?? 0 ), array( 'today', $currency ) );
|
||||
$gauge->set( (float) ( $month_revenue ?? 0 ), array( 'month', $currency ) );
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect WooCommerce customers metrics.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
private function collect_woocommerce_customers(): void {
|
||||
$gauge = $this->registry->getOrRegisterGauge(
|
||||
$this->namespace,
|
||||
'woocommerce_customers_total',
|
||||
'Total number of WooCommerce customers',
|
||||
array( 'type' )
|
||||
);
|
||||
|
||||
// Count users with customer role.
|
||||
$customer_count = count_users();
|
||||
$customers = $customer_count['avail_roles']['customer'] ?? 0;
|
||||
|
||||
$gauge->set( $customers, array( 'registered' ) );
|
||||
|
||||
// Count guest orders (orders without user_id).
|
||||
global $wpdb;
|
||||
|
||||
$hpos_enabled = $this->is_hpos_enabled();
|
||||
|
||||
if ( $hpos_enabled ) {
|
||||
$orders_table = $wpdb->prefix . 'wc_orders';
|
||||
|
||||
// phpcs:ignore WordPress.DB.DirectDatabaseQuery
|
||||
$guest_orders = $wpdb->get_var(
|
||||
"SELECT COUNT(DISTINCT billing_email) FROM {$orders_table} WHERE customer_id = 0 AND billing_email != ''"
|
||||
);
|
||||
} else {
|
||||
// phpcs:ignore WordPress.DB.DirectDatabaseQuery
|
||||
$guest_orders = $wpdb->get_var(
|
||||
"SELECT COUNT(DISTINCT pm.meta_value) FROM {$wpdb->postmeta} pm
|
||||
JOIN {$wpdb->posts} p ON p.ID = pm.post_id
|
||||
LEFT JOIN {$wpdb->postmeta} pm2 ON pm2.post_id = p.ID AND pm2.meta_key = '_customer_user'
|
||||
WHERE pm.meta_key = '_billing_email'
|
||||
AND p.post_type = 'shop_order'
|
||||
AND (pm2.meta_value = '0' OR pm2.meta_value IS NULL)"
|
||||
);
|
||||
}
|
||||
|
||||
$gauge->set( (int) $guest_orders, array( 'guest' ) );
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect runtime metrics from stored data.
|
||||
*
|
||||
|
||||
532
src/Metrics/CustomMetricBuilder.php
Normal file
532
src/Metrics/CustomMetricBuilder.php
Normal file
@@ -0,0 +1,532 @@
|
||||
<?php
|
||||
/**
|
||||
* Custom metric builder class.
|
||||
*
|
||||
* @package WP_Prometheus
|
||||
*/
|
||||
|
||||
namespace Magdev\WpPrometheus\Metrics;
|
||||
|
||||
// Prevent direct file access.
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* CustomMetricBuilder class.
|
||||
*
|
||||
* Manages custom user-defined Prometheus metrics.
|
||||
*/
|
||||
class CustomMetricBuilder {
|
||||
|
||||
/**
|
||||
* Option name for storing custom metrics.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
const OPTION_NAME = 'wp_prometheus_custom_metrics';
|
||||
|
||||
/**
|
||||
* Export format version.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
const EXPORT_VERSION = '1.0.0';
|
||||
|
||||
/**
|
||||
* Maximum number of labels per metric.
|
||||
*
|
||||
* @var int
|
||||
*/
|
||||
const MAX_LABELS = 5;
|
||||
|
||||
/**
|
||||
* Maximum number of label value combinations per metric.
|
||||
*
|
||||
* @var int
|
||||
*/
|
||||
const MAX_LABEL_VALUES = 50;
|
||||
|
||||
/**
|
||||
* Maximum import JSON size in bytes (1 MB).
|
||||
*
|
||||
* @var int
|
||||
*/
|
||||
const MAX_IMPORT_SIZE = 1048576;
|
||||
|
||||
/**
|
||||
* Get all custom metrics.
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
public function get_all(): array {
|
||||
$metrics = get_option( self::OPTION_NAME, array() );
|
||||
return is_array( $metrics ) ? $metrics : array();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a single metric by ID.
|
||||
*
|
||||
* @param string $id Metric ID.
|
||||
* @return array|null
|
||||
*/
|
||||
public function get( string $id ): ?array {
|
||||
$metrics = $this->get_all();
|
||||
return $metrics[ $id ] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Save a metric (create or update).
|
||||
*
|
||||
* @param array $metric Metric data.
|
||||
* @return string Metric ID.
|
||||
* @throws \InvalidArgumentException If validation fails.
|
||||
*/
|
||||
public function save( array $metric ): string {
|
||||
$errors = $this->validate( $metric );
|
||||
if ( ! empty( $errors ) ) {
|
||||
throw new \InvalidArgumentException( implode( ', ', $errors ) );
|
||||
}
|
||||
|
||||
$metrics = $this->get_all();
|
||||
|
||||
// Generate ID if not provided.
|
||||
if ( empty( $metric['id'] ) ) {
|
||||
$metric['id'] = wp_generate_uuid4();
|
||||
$metric['created_at'] = time();
|
||||
}
|
||||
|
||||
$metric['updated_at'] = time();
|
||||
|
||||
// Sanitize and normalize the metric data.
|
||||
$metric = $this->sanitize_metric( $metric );
|
||||
|
||||
$metrics[ $metric['id'] ] = $metric;
|
||||
update_option( self::OPTION_NAME, $metrics );
|
||||
|
||||
return $metric['id'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a metric.
|
||||
*
|
||||
* @param string $id Metric ID.
|
||||
* @return bool True if deleted, false if not found.
|
||||
*/
|
||||
public function delete( string $id ): bool {
|
||||
$metrics = $this->get_all();
|
||||
|
||||
if ( ! isset( $metrics[ $id ] ) ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
unset( $metrics[ $id ] );
|
||||
update_option( self::OPTION_NAME, $metrics );
|
||||
|
||||
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.
|
||||
*
|
||||
* @param string $name Metric name.
|
||||
* @return bool True if valid.
|
||||
*/
|
||||
public function validate_name( string $name ): bool {
|
||||
// Prometheus metric names must match: [a-zA-Z_:][a-zA-Z0-9_:]*
|
||||
return (bool) preg_match( '/^[a-zA-Z_:][a-zA-Z0-9_:]*$/', $name );
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a Prometheus label name.
|
||||
*
|
||||
* @param string $name Label name.
|
||||
* @return bool True if valid.
|
||||
*/
|
||||
public function validate_label_name( string $name ): bool {
|
||||
// Prometheus label names must match: [a-zA-Z_][a-zA-Z0-9_]*
|
||||
// Labels starting with __ are reserved.
|
||||
if ( strpos( $name, '__' ) === 0 ) {
|
||||
return false;
|
||||
}
|
||||
return (bool) preg_match( '/^[a-zA-Z_][a-zA-Z0-9_]*$/', $name );
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a complete metric definition.
|
||||
*
|
||||
* @param array $metric Metric data.
|
||||
* @return array Array of error messages (empty if valid).
|
||||
*/
|
||||
public function validate( array $metric ): array {
|
||||
$errors = array();
|
||||
|
||||
// Name is required.
|
||||
if ( empty( $metric['name'] ) ) {
|
||||
$errors[] = __( 'Metric name is required.', 'wp-prometheus' );
|
||||
} elseif ( ! $this->validate_name( $metric['name'] ) ) {
|
||||
$errors[] = __( 'Metric name must start with a letter, underscore, or colon, and contain only letters, numbers, underscores, and colons.', 'wp-prometheus' );
|
||||
}
|
||||
|
||||
// Check for reserved prefixes.
|
||||
if ( ! empty( $metric['name'] ) ) {
|
||||
$reserved_prefixes = array( 'wordpress_', 'go_', 'process_', 'promhttp_' );
|
||||
foreach ( $reserved_prefixes as $prefix ) {
|
||||
if ( strpos( $metric['name'], $prefix ) === 0 ) {
|
||||
$errors[] = sprintf(
|
||||
/* translators: %s: Reserved prefix */
|
||||
__( 'Metric name cannot start with reserved prefix "%s".', 'wp-prometheus' ),
|
||||
$prefix
|
||||
);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check for duplicate names (excluding current metric if editing).
|
||||
if ( ! empty( $metric['name'] ) ) {
|
||||
$existing = $this->get_all();
|
||||
foreach ( $existing as $id => $existing_metric ) {
|
||||
if ( $existing_metric['name'] === $metric['name'] && ( empty( $metric['id'] ) || $metric['id'] !== $id ) ) {
|
||||
$errors[] = __( 'A metric with this name already exists.', 'wp-prometheus' );
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Help text is required.
|
||||
if ( empty( $metric['help'] ) ) {
|
||||
$errors[] = __( 'Help text is required.', 'wp-prometheus' );
|
||||
}
|
||||
|
||||
// Validate type.
|
||||
$valid_types = array( 'gauge' );
|
||||
if ( empty( $metric['type'] ) || ! in_array( $metric['type'], $valid_types, true ) ) {
|
||||
$errors[] = __( 'Invalid metric type. Only gauge is supported.', 'wp-prometheus' );
|
||||
}
|
||||
|
||||
// Validate labels.
|
||||
if ( ! empty( $metric['labels'] ) ) {
|
||||
if ( ! is_array( $metric['labels'] ) ) {
|
||||
$errors[] = __( 'Labels must be an array.', 'wp-prometheus' );
|
||||
} elseif ( count( $metric['labels'] ) > self::MAX_LABELS ) {
|
||||
$errors[] = sprintf(
|
||||
/* translators: %d: Maximum labels */
|
||||
__( 'Maximum %d labels allowed per metric.', 'wp-prometheus' ),
|
||||
self::MAX_LABELS
|
||||
);
|
||||
} else {
|
||||
foreach ( $metric['labels'] as $label ) {
|
||||
if ( ! $this->validate_label_name( $label ) ) {
|
||||
$errors[] = sprintf(
|
||||
/* translators: %s: Label name */
|
||||
__( 'Invalid label name: %s', 'wp-prometheus' ),
|
||||
$label
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Validate value type.
|
||||
$valid_value_types = array( 'static', 'option' );
|
||||
if ( empty( $metric['value_type'] ) || ! in_array( $metric['value_type'], $valid_value_types, true ) ) {
|
||||
$errors[] = __( 'Invalid value type. Must be "static" or "option".', 'wp-prometheus' );
|
||||
}
|
||||
|
||||
// Validate value config based on type.
|
||||
if ( ! empty( $metric['value_type'] ) ) {
|
||||
if ( 'static' === $metric['value_type'] ) {
|
||||
// Static values validated in label_values.
|
||||
} elseif ( 'option' === $metric['value_type'] ) {
|
||||
if ( empty( $metric['value_config']['option_name'] ) ) {
|
||||
$errors[] = __( 'Option name is required for option-based metrics.', 'wp-prometheus' );
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Validate label values count.
|
||||
if ( ! empty( $metric['label_values'] ) && is_array( $metric['label_values'] ) ) {
|
||||
if ( count( $metric['label_values'] ) > self::MAX_LABEL_VALUES ) {
|
||||
$errors[] = sprintf(
|
||||
/* translators: %d: Maximum label combinations */
|
||||
__( 'Maximum %d label value combinations allowed.', 'wp-prometheus' ),
|
||||
self::MAX_LABEL_VALUES
|
||||
);
|
||||
}
|
||||
|
||||
// Validate each row has correct number of values.
|
||||
$label_count = count( $metric['labels'] ?? array() );
|
||||
foreach ( $metric['label_values'] as $row ) {
|
||||
if ( is_array( $row ) && count( $row ) !== $label_count + 1 ) { // +1 for value.
|
||||
$errors[] = __( 'Each label value row must have values for all labels plus a metric value.', 'wp-prometheus' );
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $errors;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize metric data.
|
||||
*
|
||||
* @param array $metric Raw metric data.
|
||||
* @return array Sanitized metric data.
|
||||
*/
|
||||
private function sanitize_metric( array $metric ): array {
|
||||
$sanitized = array(
|
||||
'id' => sanitize_key( $metric['id'] ?? '' ),
|
||||
'name' => self::sanitize_metric_name( $metric['name'] ?? '' ),
|
||||
'help' => sanitize_text_field( $metric['help'] ?? '' ),
|
||||
'type' => sanitize_key( $metric['type'] ?? 'gauge' ),
|
||||
'labels' => array(),
|
||||
'value_type' => sanitize_key( $metric['value_type'] ?? 'static' ),
|
||||
'value_config' => array(),
|
||||
'label_values' => array(),
|
||||
'enabled' => ! empty( $metric['enabled'] ),
|
||||
'created_at' => absint( $metric['created_at'] ?? time() ),
|
||||
'updated_at' => absint( $metric['updated_at'] ?? time() ),
|
||||
);
|
||||
|
||||
// Sanitize labels.
|
||||
if ( ! empty( $metric['labels'] ) && is_array( $metric['labels'] ) ) {
|
||||
foreach ( $metric['labels'] as $label ) {
|
||||
$sanitized['labels'][] = sanitize_key( $label );
|
||||
}
|
||||
}
|
||||
|
||||
// Sanitize value config.
|
||||
if ( 'static' === $sanitized['value_type'] ) {
|
||||
$sanitized['value_config'] = array();
|
||||
} elseif ( 'option' === $sanitized['value_type'] ) {
|
||||
$sanitized['value_config'] = array(
|
||||
'option_name' => sanitize_key( $metric['value_config']['option_name'] ?? '' ),
|
||||
'default' => floatval( $metric['value_config']['default'] ?? 0 ),
|
||||
);
|
||||
}
|
||||
|
||||
// Sanitize label values.
|
||||
if ( ! empty( $metric['label_values'] ) && is_array( $metric['label_values'] ) ) {
|
||||
foreach ( $metric['label_values'] as $row ) {
|
||||
if ( is_array( $row ) ) {
|
||||
$sanitized_row = array();
|
||||
foreach ( $row as $index => $value ) {
|
||||
// Last value is the metric value (numeric).
|
||||
if ( $index === count( $row ) - 1 ) {
|
||||
$sanitized_row[] = floatval( $value );
|
||||
} else {
|
||||
$sanitized_row[] = sanitize_text_field( $value );
|
||||
}
|
||||
}
|
||||
$sanitized['label_values'][] = $sanitized_row;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $sanitized;
|
||||
}
|
||||
|
||||
/**
|
||||
* Export all metrics to JSON.
|
||||
*
|
||||
* @return string JSON string.
|
||||
*/
|
||||
public function export(): string {
|
||||
$metrics = $this->get_all();
|
||||
|
||||
$export_data = array(
|
||||
'version' => self::EXPORT_VERSION,
|
||||
'plugin_version' => WP_PROMETHEUS_VERSION,
|
||||
'exported_at' => gmdate( 'c' ),
|
||||
'metrics' => array_values( $metrics ),
|
||||
);
|
||||
|
||||
return wp_json_encode( $export_data, JSON_PRETTY_PRINT );
|
||||
}
|
||||
|
||||
/**
|
||||
* Import metrics from JSON.
|
||||
*
|
||||
* @param string $json JSON string.
|
||||
* @param string $mode Import mode: 'skip', 'overwrite', or 'rename'.
|
||||
* @return array Result with 'imported', 'skipped', 'errors' counts.
|
||||
* @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 ) {
|
||||
throw new \InvalidArgumentException( __( 'Invalid JSON format.', 'wp-prometheus' ) );
|
||||
}
|
||||
|
||||
if ( empty( $data['metrics'] ) || ! is_array( $data['metrics'] ) ) {
|
||||
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,
|
||||
'errors' => 0,
|
||||
'messages' => array(),
|
||||
);
|
||||
|
||||
$existing_metrics = $this->get_all();
|
||||
$existing_names = array_column( $existing_metrics, 'name', 'id' );
|
||||
|
||||
foreach ( $data['metrics'] as $metric ) {
|
||||
if ( empty( $metric['name'] ) ) {
|
||||
$result['errors']++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check for name collision.
|
||||
$name_exists = in_array( $metric['name'], $existing_names, true );
|
||||
|
||||
if ( $name_exists ) {
|
||||
if ( 'skip' === $mode ) {
|
||||
$result['skipped']++;
|
||||
$result['messages'][] = sprintf(
|
||||
/* translators: %s: Metric name */
|
||||
__( 'Skipped "%s" (already exists).', 'wp-prometheus' ),
|
||||
$metric['name']
|
||||
);
|
||||
continue;
|
||||
} elseif ( 'rename' === $mode ) {
|
||||
// Generate unique name.
|
||||
$base_name = $metric['name'];
|
||||
$counter = 1;
|
||||
while ( in_array( $metric['name'], $existing_names, true ) ) {
|
||||
$metric['name'] = $base_name . '_imported_' . $counter;
|
||||
$counter++;
|
||||
}
|
||||
}
|
||||
// 'overwrite' mode: continue with same name, will overwrite below.
|
||||
}
|
||||
|
||||
// Clear ID to create new metric (unless overwriting).
|
||||
if ( 'overwrite' === $mode && $name_exists ) {
|
||||
// Find existing ID by name.
|
||||
$metric['id'] = array_search( $metric['name'], $existing_names, true );
|
||||
} else {
|
||||
unset( $metric['id'] );
|
||||
}
|
||||
|
||||
try {
|
||||
$this->save( $metric );
|
||||
$result['imported']++;
|
||||
|
||||
// Update existing names for subsequent collision checks.
|
||||
$existing_names = array_column( $this->get_all(), 'name', 'id' );
|
||||
} catch ( \InvalidArgumentException $e ) {
|
||||
$result['errors']++;
|
||||
$result['messages'][] = sprintf(
|
||||
/* translators: 1: Metric name, 2: Error message */
|
||||
__( 'Error importing "%1$s": %2$s', 'wp-prometheus' ),
|
||||
$metric['name'],
|
||||
$e->getMessage()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Register custom metrics with the Collector.
|
||||
*
|
||||
* @param Collector $collector The metrics collector instance.
|
||||
* @return void
|
||||
*/
|
||||
public function register_with_collector( Collector $collector ): void {
|
||||
$metrics = $this->get_all();
|
||||
|
||||
foreach ( $metrics as $metric ) {
|
||||
if ( empty( $metric['enabled'] ) ) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
$gauge = $collector->register_gauge(
|
||||
$metric['name'],
|
||||
$metric['help'],
|
||||
$metric['labels'] ?? array()
|
||||
);
|
||||
|
||||
// Set values based on value type.
|
||||
if ( 'option' === $metric['value_type'] ) {
|
||||
// Option-based metric: read from WordPress option.
|
||||
$option_name = $metric['value_config']['option_name'] ?? '';
|
||||
$default = $metric['value_config']['default'] ?? 0;
|
||||
|
||||
if ( ! empty( $option_name ) ) {
|
||||
$value = get_option( $option_name, $default );
|
||||
$value = is_numeric( $value ) ? floatval( $value ) : $default;
|
||||
|
||||
// For option-based, use empty labels if no labels defined.
|
||||
$label_values = array();
|
||||
if ( ! empty( $metric['labels'] ) && ! empty( $metric['label_values'][0] ) ) {
|
||||
// Use first row of labels (without the value).
|
||||
$label_values = array_slice( $metric['label_values'][0], 0, count( $metric['labels'] ) );
|
||||
}
|
||||
|
||||
$gauge->set( $value, $label_values );
|
||||
}
|
||||
} elseif ( 'static' === $metric['value_type'] ) {
|
||||
// Static metric: use predefined label values.
|
||||
if ( ! empty( $metric['label_values'] ) ) {
|
||||
foreach ( $metric['label_values'] as $row ) {
|
||||
if ( ! is_array( $row ) || count( $row ) < 1 ) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Last element is the value.
|
||||
$value = array_pop( $row );
|
||||
|
||||
// Remaining elements are label values.
|
||||
$gauge->set( floatval( $value ), $row );
|
||||
}
|
||||
} else {
|
||||
// No labels, single value.
|
||||
$gauge->set( 0, array() );
|
||||
}
|
||||
}
|
||||
} catch ( \Exception $e ) {
|
||||
// Log error but don't break metric collection.
|
||||
if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) {
|
||||
error_log( sprintf( 'WP Prometheus: Failed to register custom metric "%s": %s', $metric['name'], $e->getMessage() ) );
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
502
src/Metrics/StorageFactory.php
Normal file
502
src/Metrics/StorageFactory.php
Normal file
@@ -0,0 +1,502 @@
|
||||
<?php
|
||||
/**
|
||||
* Storage factory for Prometheus metrics.
|
||||
*
|
||||
* @package WP_Prometheus
|
||||
*/
|
||||
|
||||
namespace Magdev\WpPrometheus\Metrics;
|
||||
|
||||
use Prometheus\Storage\Adapter;
|
||||
use Prometheus\Storage\InMemory;
|
||||
use Prometheus\Storage\Redis;
|
||||
use Prometheus\Storage\APC;
|
||||
use Prometheus\Exception\StorageException;
|
||||
|
||||
// Prevent direct file access.
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* StorageFactory class.
|
||||
*
|
||||
* Creates and configures storage adapters for Prometheus metrics.
|
||||
* Supports configuration via WordPress options or environment variables.
|
||||
*/
|
||||
class StorageFactory {
|
||||
|
||||
/**
|
||||
* Available storage adapters.
|
||||
*/
|
||||
public const ADAPTER_INMEMORY = 'inmemory';
|
||||
public const ADAPTER_REDIS = 'redis';
|
||||
public const ADAPTER_APCU = 'apcu';
|
||||
|
||||
/**
|
||||
* Environment variable names.
|
||||
*/
|
||||
private const ENV_STORAGE_ADAPTER = 'WP_PROMETHEUS_STORAGE_ADAPTER';
|
||||
private const ENV_REDIS_HOST = 'WP_PROMETHEUS_REDIS_HOST';
|
||||
private const ENV_REDIS_PORT = 'WP_PROMETHEUS_REDIS_PORT';
|
||||
private const ENV_REDIS_PASSWORD = 'WP_PROMETHEUS_REDIS_PASSWORD';
|
||||
private const ENV_REDIS_DATABASE = 'WP_PROMETHEUS_REDIS_DATABASE';
|
||||
private const ENV_REDIS_PREFIX = 'WP_PROMETHEUS_REDIS_PREFIX';
|
||||
private const ENV_APCU_PREFIX = 'WP_PROMETHEUS_APCU_PREFIX';
|
||||
|
||||
/**
|
||||
* Default Redis prefix.
|
||||
*/
|
||||
private const DEFAULT_REDIS_PREFIX = 'WORDPRESS_PROMETHEUS_';
|
||||
|
||||
/**
|
||||
* Default APCu prefix.
|
||||
*/
|
||||
private const DEFAULT_APCU_PREFIX = 'wp_prom';
|
||||
|
||||
/**
|
||||
* Singleton instance of the storage adapter.
|
||||
*
|
||||
* @var Adapter|null
|
||||
*/
|
||||
private static ?Adapter $instance = null;
|
||||
|
||||
/**
|
||||
* Last error message.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
private static string $last_error = '';
|
||||
|
||||
/**
|
||||
* Get the storage adapter instance.
|
||||
*
|
||||
* @return Adapter
|
||||
*/
|
||||
public static function get_adapter(): Adapter {
|
||||
if ( null === self::$instance ) {
|
||||
self::$instance = self::create_adapter();
|
||||
}
|
||||
return self::$instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset the singleton instance (useful for testing or config changes).
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public static function reset(): void {
|
||||
self::$instance = null;
|
||||
self::$last_error = '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the last error message.
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public static function get_last_error(): string {
|
||||
return self::$last_error;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get available storage adapters.
|
||||
*
|
||||
* @return array<string, string>
|
||||
*/
|
||||
public static function get_available_adapters(): array {
|
||||
return array(
|
||||
self::ADAPTER_INMEMORY => __( 'In-Memory (default, no persistence)', 'wp-prometheus' ),
|
||||
self::ADAPTER_REDIS => __( 'Redis (requires PHP Redis extension)', 'wp-prometheus' ),
|
||||
self::ADAPTER_APCU => __( 'APCu (requires APCu extension)', 'wp-prometheus' ),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a storage adapter is available on this system.
|
||||
*
|
||||
* @param string $adapter Adapter name.
|
||||
* @return bool
|
||||
*/
|
||||
public static function is_adapter_available( string $adapter ): bool {
|
||||
switch ( $adapter ) {
|
||||
case self::ADAPTER_INMEMORY:
|
||||
return true;
|
||||
|
||||
case self::ADAPTER_REDIS:
|
||||
return extension_loaded( 'redis' );
|
||||
|
||||
case self::ADAPTER_APCU:
|
||||
return extension_loaded( 'apcu' ) && apcu_enabled();
|
||||
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the configured storage adapter name.
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public static function get_configured_adapter(): string {
|
||||
// Check environment variable first.
|
||||
$env_adapter = getenv( self::ENV_STORAGE_ADAPTER );
|
||||
if ( false !== $env_adapter && ! empty( $env_adapter ) ) {
|
||||
return strtolower( $env_adapter );
|
||||
}
|
||||
|
||||
// Fall back to WordPress option.
|
||||
return get_option( 'wp_prometheus_storage_adapter', self::ADAPTER_INMEMORY );
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the active storage adapter name (may differ from configured if fallback occurred).
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public static function get_active_adapter(): string {
|
||||
// Ensure adapter is created.
|
||||
self::get_adapter();
|
||||
|
||||
$configured = self::get_configured_adapter();
|
||||
if ( self::is_adapter_available( $configured ) && empty( self::$last_error ) ) {
|
||||
return $configured;
|
||||
}
|
||||
|
||||
return self::ADAPTER_INMEMORY;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the storage adapter based on configuration.
|
||||
*
|
||||
* @return Adapter
|
||||
*/
|
||||
private static function create_adapter(): Adapter {
|
||||
$adapter = self::get_configured_adapter();
|
||||
self::$last_error = '';
|
||||
|
||||
switch ( $adapter ) {
|
||||
case self::ADAPTER_REDIS:
|
||||
return self::create_redis_adapter();
|
||||
|
||||
case self::ADAPTER_APCU:
|
||||
return self::create_apcu_adapter();
|
||||
|
||||
case self::ADAPTER_INMEMORY:
|
||||
default:
|
||||
return new InMemory();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create Redis storage adapter.
|
||||
*
|
||||
* @return Adapter
|
||||
*/
|
||||
private static function create_redis_adapter(): Adapter {
|
||||
if ( ! extension_loaded( 'redis' ) ) {
|
||||
self::$last_error = __( 'PHP Redis extension is not installed.', 'wp-prometheus' );
|
||||
return new InMemory();
|
||||
}
|
||||
|
||||
$config = self::get_redis_config();
|
||||
|
||||
try {
|
||||
Redis::setPrefix( $config['prefix'] );
|
||||
|
||||
$redis = new Redis( array(
|
||||
'host' => $config['host'],
|
||||
'port' => $config['port'],
|
||||
'password' => $config['password'] ?: null,
|
||||
'timeout' => 0.5,
|
||||
'read_timeout' => 10,
|
||||
'persistent_connections' => true,
|
||||
) );
|
||||
|
||||
// Test connection by triggering initialization.
|
||||
// The Redis adapter connects lazily, so we need to check it works.
|
||||
return $redis;
|
||||
} catch ( StorageException $e ) {
|
||||
self::$last_error = sprintf(
|
||||
/* translators: %s: Error message */
|
||||
__( 'Redis connection failed: %s', 'wp-prometheus' ),
|
||||
$e->getMessage()
|
||||
);
|
||||
return new InMemory();
|
||||
} catch ( \RedisException $e ) {
|
||||
self::$last_error = sprintf(
|
||||
/* translators: %s: Error message */
|
||||
__( 'Redis error: %s', 'wp-prometheus' ),
|
||||
$e->getMessage()
|
||||
);
|
||||
return new InMemory();
|
||||
} catch ( \Exception $e ) {
|
||||
self::$last_error = sprintf(
|
||||
/* translators: %s: Error message */
|
||||
__( 'Storage error: %s', 'wp-prometheus' ),
|
||||
$e->getMessage()
|
||||
);
|
||||
return new InMemory();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create APCu storage adapter.
|
||||
*
|
||||
* @return Adapter
|
||||
*/
|
||||
private static function create_apcu_adapter(): Adapter {
|
||||
if ( ! extension_loaded( 'apcu' ) ) {
|
||||
self::$last_error = __( 'APCu extension is not installed.', 'wp-prometheus' );
|
||||
return new InMemory();
|
||||
}
|
||||
|
||||
if ( ! apcu_enabled() ) {
|
||||
self::$last_error = __( 'APCu is installed but not enabled.', 'wp-prometheus' );
|
||||
return new InMemory();
|
||||
}
|
||||
|
||||
$prefix = self::get_apcu_prefix();
|
||||
|
||||
try {
|
||||
return new APC( $prefix );
|
||||
} catch ( StorageException $e ) {
|
||||
self::$last_error = sprintf(
|
||||
/* translators: %s: Error message */
|
||||
__( 'APCu error: %s', 'wp-prometheus' ),
|
||||
$e->getMessage()
|
||||
);
|
||||
return new InMemory();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get Redis configuration.
|
||||
*
|
||||
* @return array{host: string, port: int, password: string, database: int, prefix: string}
|
||||
*/
|
||||
public static function get_redis_config(): array {
|
||||
// Check environment variables first.
|
||||
$env_host = getenv( self::ENV_REDIS_HOST );
|
||||
$env_port = getenv( self::ENV_REDIS_PORT );
|
||||
$env_password = getenv( self::ENV_REDIS_PASSWORD );
|
||||
$env_database = getenv( self::ENV_REDIS_DATABASE );
|
||||
$env_prefix = getenv( self::ENV_REDIS_PREFIX );
|
||||
|
||||
// Get WordPress options as fallback.
|
||||
$options = get_option( 'wp_prometheus_redis_config', array() );
|
||||
|
||||
return array(
|
||||
'host' => ( false !== $env_host && ! empty( $env_host ) ) ? $env_host : ( $options['host'] ?? '127.0.0.1' ),
|
||||
'port' => ( false !== $env_port && ! empty( $env_port ) ) ? (int) $env_port : ( (int) ( $options['port'] ?? 6379 ) ),
|
||||
'password' => ( false !== $env_password ) ? $env_password : ( $options['password'] ?? '' ),
|
||||
'database' => ( false !== $env_database && ! empty( $env_database ) ) ? (int) $env_database : ( (int) ( $options['database'] ?? 0 ) ),
|
||||
'prefix' => ( false !== $env_prefix && ! empty( $env_prefix ) ) ? $env_prefix : ( $options['prefix'] ?? self::DEFAULT_REDIS_PREFIX ),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get APCu prefix.
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public static function get_apcu_prefix(): string {
|
||||
$env_prefix = getenv( self::ENV_APCU_PREFIX );
|
||||
if ( false !== $env_prefix && ! empty( $env_prefix ) ) {
|
||||
return $env_prefix;
|
||||
}
|
||||
|
||||
return get_option( 'wp_prometheus_apcu_prefix', self::DEFAULT_APCU_PREFIX );
|
||||
}
|
||||
|
||||
/**
|
||||
* Save storage configuration.
|
||||
*
|
||||
* @param array $config Configuration array.
|
||||
* @return void
|
||||
*/
|
||||
public static function save_config( array $config ): void {
|
||||
if ( isset( $config['adapter'] ) ) {
|
||||
update_option( 'wp_prometheus_storage_adapter', sanitize_key( $config['adapter'] ) );
|
||||
}
|
||||
|
||||
if ( isset( $config['redis'] ) && is_array( $config['redis'] ) ) {
|
||||
$redis_config = array(
|
||||
'host' => sanitize_text_field( $config['redis']['host'] ?? '127.0.0.1' ),
|
||||
'port' => absint( $config['redis']['port'] ?? 6379 ),
|
||||
'password' => sanitize_text_field( $config['redis']['password'] ?? '' ),
|
||||
'database' => absint( $config['redis']['database'] ?? 0 ),
|
||||
'prefix' => sanitize_key( $config['redis']['prefix'] ?? self::DEFAULT_REDIS_PREFIX ),
|
||||
);
|
||||
update_option( 'wp_prometheus_redis_config', $redis_config );
|
||||
}
|
||||
|
||||
if ( isset( $config['apcu_prefix'] ) ) {
|
||||
update_option( 'wp_prometheus_apcu_prefix', sanitize_key( $config['apcu_prefix'] ) );
|
||||
}
|
||||
|
||||
// Reset the singleton to apply new configuration.
|
||||
self::reset();
|
||||
}
|
||||
|
||||
/**
|
||||
* Test storage adapter connection.
|
||||
*
|
||||
* @param string $adapter Adapter name.
|
||||
* @param array $config Optional configuration to test.
|
||||
* @return array{success: bool, message: string}
|
||||
*/
|
||||
public static function test_connection( string $adapter, array $config = array() ): array {
|
||||
switch ( $adapter ) {
|
||||
case self::ADAPTER_REDIS:
|
||||
return self::test_redis_connection( $config );
|
||||
|
||||
case self::ADAPTER_APCU:
|
||||
return self::test_apcu_connection( $config );
|
||||
|
||||
case self::ADAPTER_INMEMORY:
|
||||
return array(
|
||||
'success' => true,
|
||||
'message' => __( 'In-Memory storage is always available.', 'wp-prometheus' ),
|
||||
);
|
||||
|
||||
default:
|
||||
return array(
|
||||
'success' => false,
|
||||
'message' => __( 'Unknown storage adapter.', 'wp-prometheus' ),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Test Redis connection.
|
||||
*
|
||||
* @param array $config Redis configuration.
|
||||
* @return array{success: bool, message: string}
|
||||
*/
|
||||
private static function test_redis_connection( array $config ): array {
|
||||
if ( ! extension_loaded( 'redis' ) ) {
|
||||
return array(
|
||||
'success' => false,
|
||||
'message' => __( 'PHP Redis extension is not installed.', 'wp-prometheus' ),
|
||||
);
|
||||
}
|
||||
|
||||
$redis_config = ! empty( $config ) ? $config : self::get_redis_config();
|
||||
|
||||
try {
|
||||
$redis = new \Redis();
|
||||
|
||||
$connected = $redis->connect(
|
||||
$redis_config['host'],
|
||||
$redis_config['port'],
|
||||
0.5 // timeout
|
||||
);
|
||||
|
||||
if ( ! $connected ) {
|
||||
return array(
|
||||
'success' => false,
|
||||
'message' => __( 'Could not connect to Redis server.', 'wp-prometheus' ),
|
||||
);
|
||||
}
|
||||
|
||||
if ( ! empty( $redis_config['password'] ) ) {
|
||||
$authenticated = $redis->auth( $redis_config['password'] );
|
||||
if ( ! $authenticated ) {
|
||||
return array(
|
||||
'success' => false,
|
||||
'message' => __( 'Redis authentication failed.', 'wp-prometheus' ),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if ( $redis_config['database'] > 0 ) {
|
||||
$redis->select( $redis_config['database'] );
|
||||
}
|
||||
|
||||
// Test with a ping.
|
||||
$pong = $redis->ping();
|
||||
$redis->close();
|
||||
|
||||
if ( $pong ) {
|
||||
return array(
|
||||
'success' => true,
|
||||
'message' => sprintf(
|
||||
/* translators: %s: Redis host:port */
|
||||
__( 'Successfully connected to Redis at %s.', 'wp-prometheus' ),
|
||||
$redis_config['host'] . ':' . $redis_config['port']
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return array(
|
||||
'success' => false,
|
||||
'message' => __( 'Redis ping failed.', 'wp-prometheus' ),
|
||||
);
|
||||
} catch ( \RedisException $e ) {
|
||||
return array(
|
||||
'success' => false,
|
||||
'message' => sprintf(
|
||||
/* translators: %s: Error message */
|
||||
__( 'Redis error: %s', 'wp-prometheus' ),
|
||||
$e->getMessage()
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Test APCu connection.
|
||||
*
|
||||
* @param array $config APCu configuration.
|
||||
* @return array{success: bool, message: string}
|
||||
*/
|
||||
private static function test_apcu_connection( array $config ): array {
|
||||
if ( ! extension_loaded( 'apcu' ) ) {
|
||||
return array(
|
||||
'success' => false,
|
||||
'message' => __( 'APCu extension is not installed.', 'wp-prometheus' ),
|
||||
);
|
||||
}
|
||||
|
||||
if ( ! apcu_enabled() ) {
|
||||
return array(
|
||||
'success' => false,
|
||||
'message' => __( 'APCu is installed but not enabled. Check your php.ini settings.', 'wp-prometheus' ),
|
||||
);
|
||||
}
|
||||
|
||||
// Test with a simple store/fetch.
|
||||
$test_key = 'wp_prometheus_test_' . time();
|
||||
$test_value = 'test_' . wp_rand();
|
||||
|
||||
$stored = apcu_store( $test_key, $test_value, 5 );
|
||||
if ( ! $stored ) {
|
||||
return array(
|
||||
'success' => false,
|
||||
'message' => __( 'APCu store operation failed.', 'wp-prometheus' ),
|
||||
);
|
||||
}
|
||||
|
||||
$fetched = apcu_fetch( $test_key );
|
||||
apcu_delete( $test_key );
|
||||
|
||||
if ( $fetched === $test_value ) {
|
||||
$info = apcu_cache_info( true );
|
||||
return array(
|
||||
'success' => true,
|
||||
'message' => sprintf(
|
||||
/* translators: %s: Memory info */
|
||||
__( 'APCu is working. Memory: %s used.', 'wp-prometheus' ),
|
||||
size_format( $info['mem_size'] ?? 0 )
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return array(
|
||||
'success' => false,
|
||||
'message' => __( 'APCu fetch operation returned unexpected value.', 'wp-prometheus' ),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
51
tests/Helpers/GlobalFunctionState.php
Normal file
51
tests/Helpers/GlobalFunctionState.php
Normal 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;
|
||||
}
|
||||
}
|
||||
376
tests/Unit/Admin/DashboardProviderTest.php
Normal file
376
tests/Unit/Admin/DashboardProviderTest.php
Normal 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'));
|
||||
}
|
||||
}
|
||||
148
tests/Unit/AuthenticationTest.php
Normal file
148
tests/Unit/AuthenticationTest.php
Normal 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());
|
||||
}
|
||||
}
|
||||
102
tests/Unit/Endpoint/MetricsEndpointTest.php
Normal file
102
tests/Unit/Endpoint/MetricsEndpointTest.php
Normal 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);
|
||||
}
|
||||
}
|
||||
189
tests/Unit/InstallerTest.php
Normal file
189
tests/Unit/InstallerTest.php
Normal 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')
|
||||
);
|
||||
}
|
||||
}
|
||||
155
tests/Unit/Metrics/CollectorTest.php
Normal file
155
tests/Unit/Metrics/CollectorTest.php
Normal 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);
|
||||
}
|
||||
}
|
||||
655
tests/Unit/Metrics/CustomMetricBuilderTest.php
Normal file
655
tests/Unit/Metrics/CustomMetricBuilderTest.php
Normal 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,
|
||||
];
|
||||
}
|
||||
}
|
||||
234
tests/Unit/Metrics/RuntimeCollectorTest.php
Normal file
234
tests/Unit/Metrics/RuntimeCollectorTest.php
Normal 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);
|
||||
}
|
||||
}
|
||||
411
tests/Unit/Metrics/StorageFactoryTest.php
Normal file
411
tests/Unit/Metrics/StorageFactoryTest.php
Normal 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
32
tests/Unit/TestCase.php
Normal 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
450
tests/bootstrap.php
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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.1.1
|
||||
* Version: 0.5.1
|
||||
* Requires at least: 6.4
|
||||
* Requires PHP: 8.3
|
||||
* Author: Marco Graetsch
|
||||
@@ -21,12 +21,185 @@ if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Early metrics request detection.
|
||||
*
|
||||
* Detects /metrics requests early and removes problematic content filters
|
||||
* to prevent recursion issues with Twig-based plugins. Unlike the previous
|
||||
* "early mode", this allows WordPress to continue loading so that third-party
|
||||
* plugins can register their wp_prometheus_collect_metrics hooks.
|
||||
*
|
||||
* Two modes are available:
|
||||
* - Safe mode (default): Removes filters early, lets WP load, fires custom hooks
|
||||
* - Isolated mode: Outputs metrics immediately without custom hooks (legacy early mode)
|
||||
*/
|
||||
function wp_prometheus_early_metrics_check(): void {
|
||||
// Only handle /metrics requests.
|
||||
$request_uri = isset( $_SERVER['REQUEST_URI'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REQUEST_URI'] ) ) : '';
|
||||
$path = wp_parse_url( $request_uri, PHP_URL_PATH );
|
||||
|
||||
if ( ! preg_match( '#/metrics/?$#', $path ) ) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Set flag to indicate we're handling a metrics request.
|
||||
define( 'WP_PROMETHEUS_METRICS_REQUEST', true );
|
||||
|
||||
// Check if isolated mode is enabled via environment variable.
|
||||
$env_isolated = getenv( 'WP_PROMETHEUS_ISOLATED_MODE' );
|
||||
$isolated_mode = false !== $env_isolated && in_array( strtolower( $env_isolated ), array( '1', 'true', 'yes', 'on' ), true );
|
||||
|
||||
// Check if isolated mode is enabled via option (legacy "early mode" setting).
|
||||
if ( ! $isolated_mode && ! get_option( 'wp_prometheus_disable_early_mode', false ) ) {
|
||||
// Check for legacy isolated mode option.
|
||||
$isolated_mode = (bool) get_option( 'wp_prometheus_isolated_mode', false );
|
||||
}
|
||||
|
||||
// Remove all content filters immediately to prevent recursion with Twig-based plugins.
|
||||
// This is done for BOTH safe mode and isolated mode.
|
||||
add_action( 'plugins_loaded', 'wp_prometheus_remove_content_filters', 0 );
|
||||
|
||||
// Also remove filters now in case they were added by mu-plugins.
|
||||
wp_prometheus_remove_content_filters();
|
||||
|
||||
// If isolated mode is enabled, handle metrics immediately without waiting for plugins.
|
||||
if ( $isolated_mode ) {
|
||||
wp_prometheus_isolated_metrics_handler();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove content filters that can cause recursion.
|
||||
*
|
||||
* Called early during metrics requests to prevent infinite loops
|
||||
* with Twig-based plugins that hook into content filters.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
function wp_prometheus_remove_content_filters(): void {
|
||||
remove_all_filters( 'the_content' );
|
||||
remove_all_filters( 'the_excerpt' );
|
||||
remove_all_filters( 'get_the_excerpt' );
|
||||
remove_all_filters( 'the_title' );
|
||||
remove_all_filters( 'the_content_feed' );
|
||||
remove_all_filters( 'comment_text' );
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle metrics in isolated mode (no custom hooks).
|
||||
*
|
||||
* This is the legacy "early mode" that outputs metrics immediately
|
||||
* without allowing third-party plugins to add custom metrics.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
function wp_prometheus_isolated_metrics_handler(): void {
|
||||
// Check if autoloader exists.
|
||||
$autoloader = __DIR__ . '/vendor/autoload.php';
|
||||
if ( ! file_exists( $autoloader ) ) {
|
||||
return;
|
||||
}
|
||||
|
||||
require_once $autoloader;
|
||||
|
||||
// Check license validity.
|
||||
if ( ! \Magdev\WpPrometheus\License\Manager::is_license_valid() ) {
|
||||
return; // Let normal flow handle unlicensed state.
|
||||
}
|
||||
|
||||
// Authenticate 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' );
|
||||
echo 'Unauthorized';
|
||||
exit;
|
||||
}
|
||||
|
||||
// Set flag to indicate isolated mode - Collector will skip extensibility hooks.
|
||||
define( 'WP_PROMETHEUS_ISOLATED_MODE', true );
|
||||
|
||||
// Output metrics and exit immediately.
|
||||
$collector = new \Magdev\WpPrometheus\Metrics\Collector();
|
||||
|
||||
status_header( 200 );
|
||||
header( 'Content-Type: text/plain; version=0.0.4; charset=utf-8' );
|
||||
header( 'Cache-Control: no-cache, no-store, must-revalidate' );
|
||||
header( 'Pragma: no-cache' );
|
||||
header( 'Expires: 0' );
|
||||
|
||||
// phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Prometheus format.
|
||||
echo $collector->render();
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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();
|
||||
|
||||
/**
|
||||
* Plugin version.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
define( 'WP_PROMETHEUS_VERSION', '0.1.1' );
|
||||
define( 'WP_PROMETHEUS_VERSION', '0.5.1' );
|
||||
|
||||
/**
|
||||
* Plugin file path.
|
||||
|
||||
Reference in New Issue
Block a user