diff --git a/README.md b/README.md index f487f90..e1b2589 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,11 @@ # Hypercart Query Guard +## Quick test + +Append `?hcqg_test=1` to any URL while logged in as admin. This fires a 6-second `SELECT SLEEP(6)` that triggers the slow-query logging pipeline. Check `debug.log` (or your Hypercart_Logger output) for a `slow_query` event to confirm end-to-end operation. + +--- + A PHP-side circuit breaker for WordPress that enforces MySQL `MAX_EXECUTION_TIME` on read queries to prevent runaway `SELECT`s from saturating a managed-hosting pod. Built for high-volume WooCommerce stores on managed hosts (WP Engine, Pressable, Kinsta) where you don't have access to `pt-kill` or shell-level MySQL controls. Solves the failure mode where a single bad admin search, a stuck background sync, or an unindexed plugin query takes down the entire site by exhausting CPU and PHP-FPM workers. @@ -21,16 +27,39 @@ Built for high-volume WooCommerce stores on managed hosts (WP Engine, Pressable, ## Installation -Copy all plugin PHP files into `wp-content/mu-plugins/`: +### MU-plugin (required) + +Copy **all four** PHP files into `wp-content/mu-plugins/`. All four must be present — if any companion file is missing, the plugin logs a warning and disables itself rather than crashing the site. + +```text +wp-content/mu-plugins/class-hcqg-load-monitor.php ← copy first +wp-content/mu-plugins/class-hcqg-priority-registry.php ← copy first +wp-content/mu-plugins/class-hcqg-mutex-guard.php ← copy first +wp-content/mu-plugins/hypercart-query-guard.php ← copy last +``` + +**Upload order matters on live sites.** WordPress loads mu-plugins on every request with no activation step. Copy the three `class-hcqg-*.php` files first, then `hypercart-query-guard.php` last. If a request arrives after the main file is uploaded but before the companions, and the companions are missing, the plugin will safely disable itself for that request. + +### db.php drop-in (optional, recommended) + +The v2 drop-in extends `wpdb` to provide two capabilities the mu-plugin alone cannot: + +1. **Early-query coverage** — applies `SET SESSION MAX_EXECUTION_TIME` at connection time, before `wp_load_alloptions()` and other pre-`init` queries. +2. **Zero-overhead observation** — replaces WordPress core's `SAVEQUERIES` (which calls `debug_backtrace()` on every query unconditionally) with conditional backtracing that only captures slow queries. This drops the CPU cost of 100% observation from ~10% to near zero. + +Copy `db.php` to `wp-content/`: ```text -wp-content/mu-plugins/hypercart-query-guard.php -wp-content/mu-plugins/class-hcqg-load-monitor.php -wp-content/mu-plugins/class-hcqg-priority-registry.php -wp-content/mu-plugins/class-hcqg-mutex-guard.php +wp-content/db.php ``` -No activation step. MU-plugins load automatically. +The drop-in is independent of the mu-plugin and has no file dependencies. It can be installed before, after, or without the mu-plugin: + +- **db.php present, mu-plugin present** — full v2 behavior. The mu-plugin detects the drop-in and uses it for logging and tiered limit updates. +- **db.php present, mu-plugin absent** — drop-in runs standalone. Conditional backtracing and SET SESSION protection are active, but there is no structured logging, no tiered limits, and no admin-search fallback notice. +- **db.php absent, mu-plugin present** — v1 behavior. The mu-plugin hooks `init` at priority 1 and uses `SAVEQUERIES` for observation. + +**Note:** WordPress only supports one `wp-content/db.php` at a time. If another plugin (e.g., Query Monitor) has installed a db.php, you must remove it first. Query Monitor's db.php forces `SAVEQUERIES = true` on every request — even when QM is deactivated — adding ~10% CPU overhead with no benefit if QM isn't actively in use. ## Configuration @@ -221,6 +250,10 @@ Event types: - **Each defer creates a new `wp_actionscheduler_actions` row.** The original is canceled, the deferred clone is pending in the future. Under sustained critical load this can grow the canceled-row population materially before AS pruning catches up. Monitor `wp_actionscheduler_actions` row counts during enforce-mode rollouts. - **Benchmark the queue-depth probe on large stores before enforce.** The due-queue-depth probe is cheap on a healthy `actionscheduler_actions` index, but it is still a real SQL query. Use `test_observe` first and inspect the logged probe timings before enabling `enforce`. +## Future: single-file distribution + +The mu-plugin currently ships as four files. This keeps subsystems cleanly separated during development but creates deployment friction — partial uploads can fatal a site if the main file arrives before its companions (the dependency guard prevents this now, but the plugin silently disables itself until all files are present). A future improvement is a build step that concatenates all four files into a single `hypercart-query-guard.php` for distribution, eliminating upload-order concerns entirely. + ## Architecture For an engineering-level overview of how the plugin is put together — subsystem boundaries, the throttle decision pipeline, cross-request state model, mode matrix, public filter surface, and the testing approach — see [ARCHITECTURE.md](ARCHITECTURE.md). diff --git a/db.php b/db.php new file mode 100644 index 0000000..3d11f3b --- /dev/null +++ b/db.php @@ -0,0 +1,230 @@ +queries: [ $sql, $elapsed_s, $caller, $start_µs, $data ]. + * + * @var array + */ + public $hcqg_slow_queries = array(); + + /** @var bool Whether conditional backtracing is active for this request. */ + private $hcqg_active = false; + + /** @var bool Whether enforce-mode SET SESSION should be applied. */ + private $hcqg_enforce = false; + + /** @var bool Whether the first-query SET SESSION has been applied. */ + private $hcqg_session_applied = false; + + /** @var mixed Connection identity for reconnect / rotation detection. */ + private $hcqg_last_dbh = null; + + /** @var float Warn threshold in seconds. */ + private $hcqg_warn_threshold_s = 5.0; + + /** @var int Current MAX_EXECUTION_TIME limit in ms. */ + private $hcqg_current_limit_ms = 30000; + + /** + * @param string $dbuser + * @param string $dbpassword + * @param string $dbname + * @param string $dbhost + */ + public function __construct( $dbuser, $dbpassword, $dbname, $dbhost ) { + $this->hcqg_resolve_config(); + parent::__construct( $dbuser, $dbpassword, $dbname, $dbhost ); + $this->hcqg_version_check(); + } + + /** + * Read mode and thresholds from wp-config.php constants. + */ + private function hcqg_resolve_config() { + $mode = 'observe'; + if ( defined( 'HYPERCART_QUERY_GUARD_MODE' ) ) { + $raw = HYPERCART_QUERY_GUARD_MODE; + if ( in_array( $raw, array( 'off', 'observe', 'enforce' ), true ) ) { + $mode = $raw; + } + } + + $this->hcqg_active = ( 'off' !== $mode ); + $this->hcqg_enforce = ( 'enforce' === $mode ); + + if ( defined( 'HYPERCART_QUERY_GUARD_WARN_THRESHOLD_MS' ) ) { + $this->hcqg_warn_threshold_s = max( 0, (int) HYPERCART_QUERY_GUARD_WARN_THRESHOLD_MS ) / 1000; + } + + if ( defined( 'HYPERCART_QUERY_GUARD_DEFAULT_LIMIT_MS' ) ) { + $this->hcqg_current_limit_ms = max( 0, (int) HYPERCART_QUERY_GUARD_DEFAULT_LIMIT_MS ); + } + } + + /** + * Log a notice if WordPress version is outside the tested range. + */ + private function hcqg_version_check() { + global $wp_version; + + if ( ! isset( $wp_version ) ) { + return; + } + + if ( version_compare( $wp_version, self::WP_VERSION_FLOOR, '<' ) || + version_compare( $wp_version, self::WP_VERSION_CEILING, '>' ) ) { + error_log( sprintf( + '[hypercart_query_guard][warn] db.php drop-in tested on WP %s–%s; running %s. ' + . 'Verify wpdb::query() override compatibility.', + self::WP_VERSION_FLOOR, + self::WP_VERSION_CEILING, + $wp_version + ) ); + } + } + + /** + * Override query() for conditional backtracing and first-query SET SESSION. + * + * @param string $query SQL query. + * @return int|bool + */ + public function query( $query ) { + // Enforce: apply or re-apply SET SESSION on first query / reconnect / rotation. + if ( $this->hcqg_enforce && + ( ! $this->hcqg_session_applied || $this->hcqg_last_dbh !== $this->dbh ) ) { + $this->hcqg_apply_session(); + } + + // Off mode: no timing, no backtracing. + if ( ! $this->hcqg_active ) { + return parent::query( $query ); + } + + $start = microtime( true ); + $result = parent::query( $query ); + $elapsed = microtime( true ) - $start; + + // Conditional backtrace: ~0.1ms cost, only for slow queries. + if ( $elapsed >= $this->hcqg_warn_threshold_s ) { + $this->hcqg_slow_queries[] = array( + $query, + $elapsed, + $this->get_caller(), + $start, + array(), + ); + } + + return $result; + } + + /** + * Apply SET SESSION MAX_EXECUTION_TIME via raw mysqli. + * Bypasses $this->query() to avoid recursion and timing noise. + */ + private function hcqg_apply_session() { + if ( empty( $this->dbh ) || ! ( $this->dbh instanceof mysqli ) ) { + return; + } + + $limit_ms = $this->hcqg_current_limit_ms; + + if ( defined( 'WP_CLI' ) && WP_CLI ) { + $limit_ms = 0; + } + + if ( $limit_ms > 0 ) { + @mysqli_query( $this->dbh, sprintf( 'SET SESSION MAX_EXECUTION_TIME = %d', $limit_ms ) ); + } + + $this->hcqg_session_applied = true; + $this->hcqg_last_dbh = $this->dbh; + } + + // -- Public API for the mu-plugin ------------------------------------------ + + /** + * Update the limit after context detection and re-apply immediately. + * Called by the mu-plugin on init once the request context (admin, REST, + * checkout, etc.) is known and the correct tiered limit is resolved. + * + * Also caches the limit so reconnect/rotation re-applies the correct + * per-context value instead of the pre-init default. + * + * @param int $limit_ms Milliseconds. 0 = unlimited. + */ + public function hcqg_update_limit( $limit_ms ) { + $limit_ms = max( 0, (int) $limit_ms ); + $this->hcqg_current_limit_ms = $limit_ms; + + if ( ! $this->hcqg_enforce || empty( $this->dbh ) || ! ( $this->dbh instanceof mysqli ) ) { + return; + } + + if ( $limit_ms > 0 ) { + @mysqli_query( $this->dbh, sprintf( 'SET SESSION MAX_EXECUTION_TIME = %d', $limit_ms ) ); + } + + $this->hcqg_last_dbh = $this->dbh; + } + + /** + * Whether the drop-in's instrumentation is active for this request. + * + * @return bool + */ + public function hcqg_is_active() { + return $this->hcqg_active; + } +} + +// --------------------------------------------------------------------------- +// Instantiate the custom wpdb. WordPress's require_wp_db() checks +// `isset( $wpdb )` and skips `new wpdb(...)` if we set it here. +// --------------------------------------------------------------------------- +$wpdb = new HCQG_DB( // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited + defined( 'DB_USER' ) ? DB_USER : '', + defined( 'DB_PASSWORD' ) ? DB_PASSWORD : '', + defined( 'DB_NAME' ) ? DB_NAME : '', + defined( 'DB_HOST' ) ? DB_HOST : '' +); diff --git a/hypercart-query-guard.php b/hypercart-query-guard.php index ad11beb..7a9be30 100644 --- a/hypercart-query-guard.php +++ b/hypercart-query-guard.php @@ -35,6 +35,21 @@ exit; } +$hcqg_required_files = array( + __DIR__ . '/class-hcqg-load-monitor.php', + __DIR__ . '/class-hcqg-priority-registry.php', + __DIR__ . '/class-hcqg-mutex-guard.php', +); +foreach ( $hcqg_required_files as $hcqg_file ) { + if ( ! file_exists( $hcqg_file ) ) { + if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) { + error_log( '[hypercart_query_guard][error] Missing required file: ' . basename( $hcqg_file ) . ' — plugin disabled.' ); + } + return; + } +} +unset( $hcqg_required_files, $hcqg_file ); + require_once __DIR__ . '/class-hcqg-load-monitor.php'; require_once __DIR__ . '/class-hcqg-priority-registry.php'; require_once __DIR__ . '/class-hcqg-mutex-guard.php'; @@ -159,6 +174,16 @@ final class Hypercart_Query_Guard { */ private static $throttle_runtime = array(); + /** + * Whether the v2 db.php drop-in is active for this request. + * + * @return bool + */ + private static function dropin_active() { + global $wpdb; + return ( isset( $wpdb ) && method_exists( $wpdb, 'hcqg_is_active' ) && $wpdb->hcqg_is_active() ); + } + /** * Bootstrap. */ @@ -182,32 +207,45 @@ public static function init() { } } - // Apply the SET SESSION early. Priority 1 on 'init' is as early as - // reliably available without a db.php drop-in. if ( self::MODE_ENFORCE === $mode ) { + // v2 drop-in applies SET SESSION on first query (pre-init). + // We still hook init to refine the limit with the correct + // per-context tier (admin 45s, checkout 60s, etc.). add_action( 'init', array( __CLASS__, 'apply_session_timeout' ), 1 ); add_action( 'rest_api_init', array( __CLASS__, 'apply_session_timeout' ), 1 ); add_action( 'admin_init', array( __CLASS__, 'apply_session_timeout' ), 1 ); - // Catch the kill, log it, and surface a recovery notice in admin. - // The 'query' filter fires before each subsequent query, so we - // can capture the previous query's error before wpdb::flush() - // clears it. Without this, multiple kills in one request would - // collapse into a single log line (the last one). Shutdown is - // the fallback for the final query of the request. add_filter( 'query', array( __CLASS__, 'capture_pending_kill_filter' ), 1 ); add_action( 'shutdown', array( __CLASS__, 'detect_and_log_kill' ), 0 ); add_action( 'admin_notices', array( __CLASS__, 'render_admin_search_notice' ) ); } - // Observe mode (and enforce mode, additively) logs slow queries via - // SAVEQUERIES. Sampled to keep memory overhead bounded. - if ( self::should_observe_queries( $mode ) ) { + // v2 drop-in: conditional backtracing at 100% replaces SAVEQUERIES. + // v1 fallback: SAVEQUERIES sampled at OBSERVE_SAMPLE_PCT. + if ( self::dropin_active() ) { + if ( self::MODE_OFF !== $mode ) { + add_action( 'shutdown', array( __CLASS__, 'log_slow_queries' ), 1 ); + } + } elseif ( self::should_observe_queries( $mode ) ) { if ( ! defined( 'SAVEQUERIES' ) ) { define( 'SAVEQUERIES', true ); } add_action( 'shutdown', array( __CLASS__, 'log_slow_queries' ), 1 ); } + + add_action( 'init', array( __CLASS__, 'maybe_run_diagnostic_query' ), 99 ); + } + + /** + * Fire a SLEEP() query when ?hcqg_test=1 is present. Admin-only. + * Triggers the slow-query logging pipeline for end-to-end validation. + */ + public static function maybe_run_diagnostic_query() { + if ( ! isset( $_GET['hcqg_test'] ) || ! current_user_can( 'manage_options' ) ) { + return; + } + global $wpdb; + $wpdb->query( 'SELECT SLEEP(6)' ); } /** @@ -888,16 +926,27 @@ public static function apply_session_timeout() { return; // Unlimited contexts (WP-CLI, Action Scheduler). } + // v2 drop-in: update the cached limit and re-apply via raw mysqli. + // This also caches the per-context limit so reconnect/rotation + // re-applies the correct tier instead of the pre-init default. + if ( self::dropin_active() ) { + static $dropin_last_limit = null; + if ( $dropin_last_limit === $limit_ms ) { + return; + } + $wpdb->hcqg_update_limit( $limit_ms ); + $dropin_last_limit = $limit_ms; + return; + } + + // v1 fallback. static $last_dbh = null; static $last_limit = null; - // Skip the round-trip if both connection identity and limit are unchanged. if ( $last_dbh === $wpdb->dbh && $last_limit === $limit_ms ) { return; } - // Suppress wpdb's own error reporting for the SET itself; if MySQL - // rejects it (very old version), we don't want to break the request. $prev_suppress = $wpdb->suppress_errors( true ); $wpdb->query( $wpdb->prepare( 'SET SESSION MAX_EXECUTION_TIME = %d', $limit_ms ) ); $wpdb->suppress_errors( $prev_suppress ); @@ -1023,10 +1072,21 @@ public static function render_admin_search_notice() { public static function log_slow_queries() { global $wpdb; - if ( empty( $wpdb ) || ! defined( 'SAVEQUERIES' ) || ! SAVEQUERIES ) { + if ( empty( $wpdb ) ) { return; } - if ( empty( $wpdb->queries ) || ! is_array( $wpdb->queries ) ) { + + // v2 drop-in populates hcqg_slow_queries (already filtered by threshold). + // v1 reads from $wpdb->queries (populated by SAVEQUERIES). + if ( self::dropin_active() ) { + $queries = $wpdb->hcqg_slow_queries; + } elseif ( defined( 'SAVEQUERIES' ) && SAVEQUERIES && ! empty( $wpdb->queries ) ) { + $queries = $wpdb->queries; + } else { + return; + } + + if ( empty( $queries ) || ! is_array( $queries ) ) { return; } @@ -1034,7 +1094,7 @@ public static function log_slow_queries() { $context = self::detect_context(); $uri = isset( $_SERVER['REQUEST_URI'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REQUEST_URI'] ) ) : ''; - foreach ( $wpdb->queries as $row ) { + foreach ( $queries as $row ) { // $row = [ $query, $duration_seconds, $callstack, $start_microtime, ... ] if ( ! isset( $row[1] ) || $row[1] < $threshold_s ) { continue; diff --git a/tests/DbDropinTest.php b/tests/DbDropinTest.php new file mode 100644 index 0000000..d55dee9 --- /dev/null +++ b/tests/DbDropinTest.php @@ -0,0 +1,161 @@ +setAccessible( true ); + self::$dropin_active = $ref; + } + + protected function setUp(): void { + WP_Stub_State::reset(); + $GLOBALS['wpdb']->reset(); + Hypercart_Logger::reset(); + } + + // -- dropin_active() detection ------------------------------------------- + + public function test_dropin_active_false_by_default(): void { + $this->assertFalse( self::$dropin_active->invoke( null ) ); + } + + public function test_dropin_active_true_when_hcqg_methods_present_and_active(): void { + $GLOBALS['wpdb']->hcqg_set_active( true ); + $this->assertTrue( self::$dropin_active->invoke( null ) ); + } + + public function test_dropin_active_false_when_methods_present_but_inactive(): void { + $GLOBALS['wpdb']->hcqg_set_active( false ); + $this->assertFalse( self::$dropin_active->invoke( null ) ); + } + + // -- log_slow_queries() v2 codepath -------------------------------------- + + public function test_log_slow_queries_reads_hcqg_slow_queries_when_dropin_active(): void { + $wpdb = $GLOBALS['wpdb']; + $wpdb->hcqg_set_active( true ); + + $wpdb->hcqg_slow_queries = array( + array( + 'SELECT * FROM wp_posts WHERE ID = 1', + 6.5, // 6500ms > 5000ms threshold + 'test_caller → some_function', + microtime( true ) - 6.5, + array(), + ), + ); + + Hypercart_Query_Guard::log_slow_queries(); + + $this->assertCount( 1, Hypercart_Logger::$calls ); + $this->assertSame( 'warn', Hypercart_Logger::$calls[0]['level'] ); + $this->assertSame( 'slow_query', Hypercart_Logger::$calls[0]['payload']['event'] ); + $this->assertSame( 6500, Hypercart_Logger::$calls[0]['payload']['duration_ms'] ); + } + + public function test_log_slow_queries_skips_below_threshold_from_dropin(): void { + $wpdb = $GLOBALS['wpdb']; + $wpdb->hcqg_set_active( true ); + + $wpdb->hcqg_slow_queries = array( + array( 'SELECT 1', 3.0, 'caller', microtime( true ), array() ), + ); + + Hypercart_Query_Guard::log_slow_queries(); + + $this->assertEmpty( Hypercart_Logger::$calls ); + } + + public function test_log_slow_queries_returns_early_when_dropin_buffer_empty(): void { + $wpdb = $GLOBALS['wpdb']; + $wpdb->hcqg_set_active( true ); + $wpdb->hcqg_slow_queries = array(); + + Hypercart_Query_Guard::log_slow_queries(); + + $this->assertEmpty( Hypercart_Logger::$calls ); + } + + public function test_log_slow_queries_ignores_hcqg_buffer_without_dropin(): void { + $wpdb = $GLOBALS['wpdb']; + $wpdb->hcqg_set_active( false ); + $wpdb->hcqg_slow_queries = array( + array( 'SELECT should_be_ignored', 7.0, 'caller', microtime( true ), array() ), + ); + + Hypercart_Query_Guard::log_slow_queries(); + + $this->assertEmpty( Hypercart_Logger::$calls ); + } + + public function test_log_slow_queries_logs_multiple_slow_queries(): void { + $wpdb = $GLOBALS['wpdb']; + $wpdb->hcqg_set_active( true ); + + $wpdb->hcqg_slow_queries = array( + array( 'SELECT * FROM wp_posts', 5.5, 'caller_a', microtime( true ), array() ), + array( 'SELECT * FROM wp_options', 8.2, 'caller_b', microtime( true ), array() ), + ); + + Hypercart_Query_Guard::log_slow_queries(); + + $this->assertCount( 2, Hypercart_Logger::$calls ); + $this->assertSame( 5500, Hypercart_Logger::$calls[0]['payload']['duration_ms'] ); + $this->assertSame( 8200, Hypercart_Logger::$calls[1]['payload']['duration_ms'] ); + } + + public function test_log_slow_queries_truncates_long_sql(): void { + $wpdb = $GLOBALS['wpdb']; + $wpdb->hcqg_set_active( true ); + + $long_query = str_repeat( 'X', 1000 ); + $wpdb->hcqg_slow_queries = array( + array( $long_query, 6.0, 'caller', microtime( true ), array() ), + ); + + Hypercart_Query_Guard::log_slow_queries(); + + $this->assertCount( 1, Hypercart_Logger::$calls ); + $logged_query = Hypercart_Logger::$calls[0]['payload']['query']; + $this->assertLessThanOrEqual( 520, strlen( $logged_query ) ); + $this->assertStringContainsString( 'truncated', $logged_query ); + } + + // -- apply_session_timeout() v2 codepath --------------------------------- + + public function test_apply_session_timeout_uses_hcqg_update_limit_when_dropin_active(): void { + $wpdb = $GLOBALS['wpdb']; + $wpdb->hcqg_set_active( true ); + $wpdb->dbh = new stdClass(); + + Hypercart_Query_Guard::apply_session_timeout(); + + $this->assertSame( 30000, $wpdb->hcqg_last_limit ); + } + + public function test_apply_session_timeout_v1_fallback_without_dropin(): void { + $wpdb = $GLOBALS['wpdb']; + $wpdb->hcqg_set_active( false ); + $wpdb->dbh = new stdClass(); + + Hypercart_Query_Guard::apply_session_timeout(); + + $this->assertNotEmpty( $wpdb->queries ); + $this->assertStringContainsString( 'MAX_EXECUTION_TIME', $wpdb->queries[0] ); + } +} diff --git a/tests/bootstrap.php b/tests/bootstrap.php index 0f0f87e..eab0810 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -59,12 +59,46 @@ final class WP_Stub_DB { /** @var array FIFO queue of get_var() returns. */ public $next_get_var = array(); + // v2 drop-in simulation fields. + /** @var array Simulates HCQG_DB::$hcqg_slow_queries. */ + public $hcqg_slow_queries = array(); + /** @var bool Whether to simulate an active drop-in. */ + private $hcqg_active = false; + /** @var int|null Last limit passed to hcqg_update_limit(). */ + public $hcqg_last_limit = null; + /** @var mixed Simulated connection handle. */ + public $dbh = null; + /** @var string */ + public $last_error = ''; + /** @var int */ + public $num_queries = 0; + + public function hcqg_is_active(): bool { return $this->hcqg_active; } + public function hcqg_set_active( bool $active ): void { $this->hcqg_active = $active; } + public function hcqg_update_limit( int $limit_ms ): void { $this->hcqg_last_limit = $limit_ms; } + + /** @var bool */ + private $suppress = false; + + public function suppress_errors( $suppress = true ) { + $prev = $this->suppress; + $this->suppress = (bool) $suppress; + return $prev; + } + public function reset(): void { $this->rows_affected = 0; $this->queries = array(); $this->prepared = array(); $this->next_query_rows_affected = array(); $this->next_get_var = array(); + $this->hcqg_slow_queries = array(); + $this->hcqg_active = false; + $this->hcqg_last_limit = null; + $this->dbh = null; + $this->last_error = ''; + $this->num_queries = 0; + $this->suppress = false; } /** @@ -269,6 +303,20 @@ function esc_html__( $text, $domain = 'default' ) { } } +/** + * Stub logger that captures calls so tests can inspect log output + * without relying on error_log() interception. + */ +class Hypercart_Logger { + /** @var array */ + public static $calls = array(); + + public static function reset(): void { self::$calls = array(); } + public static function error( $channel, $payload ) { self::$calls[] = array( 'level' => 'error', 'channel' => $channel, 'payload' => $payload ); } + public static function info( $channel, $payload ) { self::$calls[] = array( 'level' => 'info', 'channel' => $channel, 'payload' => $payload ); } + public static function warn( $channel, $payload ) { self::$calls[] = array( 'level' => 'warn', 'channel' => $channel, 'payload' => $payload ); } +} + $GLOBALS['wpdb'] = new WP_Stub_DB(); require_once __DIR__ . '/../class-hcqg-load-monitor.php';