diff --git a/CHANGELOG.md b/CHANGELOG.md index 868fa1f..a801d59 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,46 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.4.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 diff --git a/CLAUDE.md b/CLAUDE.md index 4639080..68d8252 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -291,6 +291,50 @@ add_action( 'wp_prometheus_collect_metrics', function( $collector ) { ## Session History +### 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: diff --git a/assets/css/admin.css b/assets/css/admin.css index 54b908e..cb552e5 100644 --- a/assets/css/admin.css +++ b/assets/css/admin.css @@ -16,7 +16,7 @@ .wp-prometheus-subtab-nav { display: flex; - margin: 0 0 20px 0; + margin: 0; padding: 0; list-style: none; border-bottom: 1px solid #c3c4c7; diff --git a/src/Admin/Settings.php b/src/Admin/Settings.php index 9b37afb..dd527ac 100644 --- a/src/Admin/Settings.php +++ b/src/Admin/Settings.php @@ -107,18 +107,20 @@ class Settings { * @return void */ public function register_settings(): void { - // Register settings for metrics tab. - register_setting( 'wp_prometheus_metrics_settings', 'wp_prometheus_auth_token', array( + // Register settings for endpoint sub-tab. + register_setting( 'wp_prometheus_endpoint_settings', 'wp_prometheus_auth_token', array( 'type' => 'string', 'sanitize_callback' => 'sanitize_text_field', ) ); - register_setting( 'wp_prometheus_metrics_settings', 'wp_prometheus_enabled_metrics', array( + // Register settings for selection sub-tab. + register_setting( 'wp_prometheus_selection_settings', 'wp_prometheus_enabled_metrics', array( 'type' => 'array', 'sanitize_callback' => array( $this, 'sanitize_metrics' ), ) ); - register_setting( 'wp_prometheus_metrics_settings', 'wp_prometheus_disable_early_mode', array( + // Register settings for advanced sub-tab. + register_setting( 'wp_prometheus_advanced_settings', 'wp_prometheus_isolated_mode', array( 'type' => 'boolean', 'sanitize_callback' => 'rest_sanitize_boolean', 'default' => false, @@ -484,7 +486,7 @@ class Settings { private function render_metrics_endpoint_subtab(): void { ?>
- +

@@ -513,7 +515,7 @@ class Settings { private function render_metrics_selection_subtab(): void { ?> - +

@@ -566,57 +568,93 @@ class Settings { * @return void */ private function render_metrics_advanced_subtab(): void { - $disabled = get_option( 'wp_prometheus_disable_early_mode', false ); - $env_override = false !== getenv( 'WP_PROMETHEUS_DISABLE_EARLY_MODE' ); - $early_active = defined( 'WP_PROMETHEUS_EARLY_METRICS' ) && WP_PROMETHEUS_EARLY_METRICS; + $isolated_mode = get_option( 'wp_prometheus_isolated_mode', false ); + $env_override = false !== getenv( 'WP_PROMETHEUS_ISOLATED_MODE' ); + $is_metrics_request = defined( 'WP_PROMETHEUS_METRICS_REQUEST' ) && WP_PROMETHEUS_METRICS_REQUEST; + $is_isolated = defined( 'WP_PROMETHEUS_ISOLATED_MODE' ) && WP_PROMETHEUS_ISOLATED_MODE; ?> - + -

-

- -

+

+ +
+

+

+
-
+
-

+

- + +
+ +

+ + + + + + + + + + + + + + + + + + + + + + + + + +
+ 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(); } /** diff --git a/wp-prometheus.php b/wp-prometheus.php index c2ca18a..17426e8 100644 --- a/wp-prometheus.php +++ b/wp-prometheus.php @@ -3,7 +3,7 @@ * Plugin Name: WP Prometheus * Plugin URI: https://src.bundespruefstelle.ch/magdev/wp-prometheus * Description: Prometheus metrics endpoint for WordPress with extensible hooks for custom metrics. - * Version: 0.4.3 + * Version: 0.4.5 * Requires at least: 6.4 * Requires PHP: 8.3 * Author: Marco Graetsch @@ -22,11 +22,16 @@ if ( ! defined( 'ABSPATH' ) ) { } /** - * Early metrics endpoint handler. + * Early metrics request detection. * - * Intercepts /metrics requests before full WordPress initialization to avoid - * conflicts with other plugins that may cause issues during template loading. - * This runs at plugin load time, before plugins_loaded hook. + * Detects /metrics requests early and removes problematic content filters + * to prevent recursion issues with Twig-based plugins. Unlike the previous + * "early mode", this allows WordPress to continue loading so that third-party + * plugins can register their wp_prometheus_collect_metrics hooks. + * + * Two modes are available: + * - Safe mode (default): Removes filters early, lets WP load, fires custom hooks + * - Isolated mode: Outputs metrics immediately without custom hooks (legacy early mode) */ function wp_prometheus_early_metrics_check(): void { // Only handle /metrics requests. @@ -37,18 +42,58 @@ function wp_prometheus_early_metrics_check(): void { return; } - // Check if early mode is disabled via environment variable. - $env_disable = getenv( 'WP_PROMETHEUS_DISABLE_EARLY_MODE' ); - if ( false !== $env_disable && in_array( strtolower( $env_disable ), array( '1', 'true', 'yes', 'on' ), true ) ) { - 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 ); } - // Check if early mode is disabled via option. - // We can use get_option() here because WordPress core is already loaded. - if ( get_option( 'wp_prometheus_disable_early_mode', false ) ) { - return; - } + // 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 ) ) { @@ -99,14 +144,8 @@ function wp_prometheus_early_metrics_check(): void { exit; } - // Set flag to indicate early metrics mode - Collector will skip extensibility hooks. - define( 'WP_PROMETHEUS_EARLY_METRICS', true ); - - // Remove all content filters to prevent recursion with Twig-based plugins. - remove_all_filters( 'the_content' ); - remove_all_filters( 'the_excerpt' ); - remove_all_filters( 'get_the_excerpt' ); - remove_all_filters( 'the_title' ); + // Set flag to indicate isolated mode - Collector will skip extensibility hooks. + define( 'WP_PROMETHEUS_ISOLATED_MODE', true ); // Output metrics and exit immediately. $collector = new \Magdev\WpPrometheus\Metrics\Collector(); @@ -130,7 +169,7 @@ wp_prometheus_early_metrics_check(); * * @var string */ -define( 'WP_PROMETHEUS_VERSION', '0.4.3' ); +define( 'WP_PROMETHEUS_VERSION', '0.4.5' ); /** * Plugin file path.