diff --git a/CHANGELOG.md b/CHANGELOG.md index 0ca1115..29220c3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,16 @@ 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.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 diff --git a/src/Endpoint/MetricsEndpoint.php b/src/Endpoint/MetricsEndpoint.php index 9728dda..9cbce84 100644 --- a/src/Endpoint/MetricsEndpoint.php +++ b/src/Endpoint/MetricsEndpoint.php @@ -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; } diff --git a/src/Metrics/Collector.php b/src/Metrics/Collector.php index e4640b1..66eaf7b 100644 --- a/src/Metrics/Collector.php +++ b/src/Metrics/Collector.php @@ -121,9 +121,14 @@ class Collector { /** * Fires after default metrics are collected. * + * Skip in early metrics mode to avoid triggering third-party hooks + * that may cause recursion issues (e.g., Twig-based plugins). + * * @param Collector $collector The metrics collector instance. */ - do_action( 'wp_prometheus_collect_metrics', $this ); + if ( ! defined( 'WP_PROMETHEUS_EARLY_METRICS' ) || ! WP_PROMETHEUS_EARLY_METRICS ) { + do_action( 'wp_prometheus_collect_metrics', $this ); + } } /** diff --git a/wp-prometheus.php b/wp-prometheus.php index ccd0b7c..f2d4ee1 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.0 + * Version: 0.4.1 * Requires at least: 6.4 * Requires PHP: 8.3 * Author: Marco Graetsch @@ -21,12 +21,104 @@ if ( ! defined( 'ABSPATH' ) ) { exit; } +/** + * Early metrics endpoint handler. + * + * 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. + */ +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; + } + + // Check if autoloader exists. + $autoloader = __DIR__ . '/vendor/autoload.php'; + if ( ! file_exists( $autoloader ) ) { + return; + } + + require_once $autoloader; + + // Check license validity. + if ( ! \Magdev\WpPrometheus\License\Manager::is_license_valid() ) { + return; // Let normal flow handle unlicensed state. + } + + // Authenticate. + $auth_token = get_option( 'wp_prometheus_auth_token', '' ); + if ( empty( $auth_token ) ) { + status_header( 401 ); + header( 'WWW-Authenticate: Bearer realm="WP Prometheus Metrics"' ); + header( 'Content-Type: text/plain; charset=utf-8' ); + echo 'Unauthorized'; + exit; + } + + // Check Bearer token. + $auth_header = ''; + if ( isset( $_SERVER['HTTP_AUTHORIZATION'] ) ) { + $auth_header = sanitize_text_field( wp_unslash( $_SERVER['HTTP_AUTHORIZATION'] ) ); + } elseif ( isset( $_SERVER['REDIRECT_HTTP_AUTHORIZATION'] ) ) { + $auth_header = sanitize_text_field( wp_unslash( $_SERVER['REDIRECT_HTTP_AUTHORIZATION'] ) ); + } + + $authenticated = false; + if ( ! empty( $auth_header ) && preg_match( '/Bearer\s+(.*)$/i', $auth_header, $matches ) ) { + $authenticated = hash_equals( $auth_token, $matches[1] ); + } + + // Check query parameter fallback. + // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Auth token check. + if ( ! $authenticated && isset( $_GET['token'] ) ) { + $authenticated = hash_equals( $auth_token, sanitize_text_field( wp_unslash( $_GET['token'] ) ) ); + } + + if ( ! $authenticated ) { + status_header( 401 ); + header( 'WWW-Authenticate: Bearer realm="WP Prometheus Metrics"' ); + header( 'Content-Type: text/plain; charset=utf-8' ); + echo 'Unauthorized'; + exit; + } + + // Set flag to indicate early metrics mode - Collector will skip extensibility hooks. + define( 'WP_PROMETHEUS_EARLY_METRICS', true ); + + // Remove all content filters to prevent recursion with Twig-based plugins. + remove_all_filters( 'the_content' ); + remove_all_filters( 'the_excerpt' ); + remove_all_filters( 'get_the_excerpt' ); + remove_all_filters( 'the_title' ); + + // Output metrics and exit immediately. + $collector = new \Magdev\WpPrometheus\Metrics\Collector(); + + status_header( 200 ); + header( 'Content-Type: text/plain; version=0.0.4; charset=utf-8' ); + header( 'Cache-Control: no-cache, no-store, must-revalidate' ); + header( 'Pragma: no-cache' ); + header( 'Expires: 0' ); + + // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Prometheus format. + echo $collector->render(); + exit; +} + +// Try early metrics handling before full plugin initialization. +wp_prometheus_early_metrics_check(); + /** * Plugin version. * * @var string */ -define( 'WP_PROMETHEUS_VERSION', '0.4.0' ); +define( 'WP_PROMETHEUS_VERSION', '0.4.1' ); /** * Plugin file path.