You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Two independent v1 limitations share a single root cause: the plugin hooks init at priority 1 and relies on WordPress core's SAVEQUERIES constant. A wp-content/db.php drop-in resolves both.
1. Early-query coverage gap
v1 hooks init at priority 1, so queries fired before then run without a MAX_EXECUTION_TIME ceiling:
wp_load_alloptions() (autoloaded options)
Auth / usermeta lookups
WC session bootstrap
Anything on muplugins_loaded, plugins_loaded, setup_theme
On sites where these early queries are the ones that go runaway (bloated autoload table, cold object-cache miss, slow alloptions on a managed-host MySQL hiccup), v1 enforce mode provides no protection. Documented in hypercart-query-guard.php header lines 21–27.
Signs you need v2 (Noel's framing from the original issue):
kills logged with context: frontend and a last_query against wp_options
repeated alloptions slow-query warnings in observe mode
any kill whose stack trace points into plugins_loaded
2. SAVEQUERIES observation overhead
WordPress core's SAVEQUERIES calls debug_backtrace() on every query unconditionally — before checking duration, before anything. The order inside wpdb::query():
1. Start timer
2. Execute query ← duration unknown
3. Stop timer ← duration NOW known
4. debug_backtrace() ← expensive, runs unconditionally
5. Store query + duration + backtrace in $wpdb->queries
The plugin's shutdown handler then walks $wpdb->queries and only logs queries over the warn threshold (5s). But by that point, the cost has already been paid on every query.
Measured cost on a WooCommerce page (~300 queries, ~300ms PHP CPU time, ~60MB memory — from query-guard-findings.md):
Cost
Estimate
As % of normal work
Extra CPU
~30ms (300 × ~0.1ms per backtrace)
~10%
Extra memory
~1MB (SQL strings + backtrace strings)
~1.5%
Site-wide CPU overhead scales linearly with sample rate:
Sample rate
Site-wide CPU overhead
5% (v1 default in observe mode)
~0.5%
25%
~2.5%
100% (v1 enforce mode)
~10%
This ~10% at 100% sampling is what's blocking the deployment plan: observer mode at 100% prior to enforce mode at 100% is the safe rollout sequence on busy production sites, but the SAVEQUERIES cost makes 100% observation unaffordable.
The irony: query duration is already known at step 3 before the backtrace runs at step 4. Core just doesn't check it. A conditional backtrace — time every query cheaply, only backtrace the slow ones — would reduce observation overhead to near zero. But there is no hook between "duration known" and "backtrace called", and SAVEQUERIES is a blunt on/off switch.
Solution
A wp-content/db.php drop-in that extends wpdb and overrides the query path to:
1. Apply SET SESSION MAX_EXECUTION_TIME at connection time
Inside wpdb::db_connect() (or a first-query gate), so the limit is in place before wp_load_alloptions() and the rest of the pre-init query traffic. No hook dependency.
2. Implement conditional backtracing
Time every query (microsecond cost — wpdb does this anyway). Only call debug_backtrace() when duration >= warn_threshold. This replaces SAVEQUERIES with a targeted mechanism that has near-zero overhead on fast queries.
Structured logging via Hypercart_Logger (with error_log() fallback)
Re-application on connection rotation
Query Monitor db.php conflict
WordPress only supports a single wp-content/db.php drop-in. Query Monitor (QM) also ships a db.php that extends wpdb. Investigation of QM's implementation revealed the two drop-ins are mutually exclusive by design — they cannot be combined.
What QM's db.php does
QM's QM_DB class (query-monitor/classes/DB.php) extends wpdb and overrides query(). After parent::query() returns, it replaces the caller string that core stored in $wpdb->queries[$i] with a full QM_Backtrace object (richer stack trace data for QM's developer panel). It also stores the query result or error for display in the panel.
QM uses — not avoids — the SAVEQUERIES overhead
QM's db.php forces SAVEQUERIES on (line 85–87 of query-monitor/wp-content/db.php):
QM requires every query to be stored in $wpdb->queries by core. Then QM's query() override augments each entry with its own QM_Backtrace object on top of the debug_backtrace() that core already ran. The backtrace cost is effectively paid twice per query.
The QM author acknowledges the limitation in a comment inside QM_DB::query():
"It's not possible to prevent the parent class from logging queries because it reads the SAVEQUERIES constant and I don't want to override more methods than necessary."
Why combining is not viable
The two drop-ins have opposite goals:
Query Monitor (QM_DB)
Query Guard (HCQG_DB)
Purpose
Dev tool — show ALL queries in a panel
Production guard — only log slow queries
SAVEQUERIES
Requires it (needs every query stored)
Replaces it (avoids the overhead)
Backtrace
On every query (full stack for dev panel)
Only on queries exceeding threshold
Target env
Development / staging with dev tools
Production (always-on)
When active
Dev sessions only
Every request
QM needs SAVEQUERIES = true and unconditional backtracing to populate its developer panel. HCQG_DB exists specifically to eliminate that overhead. Extending QM_DB instead of wpdb would defeat the purpose of the v2 drop-in entirely.
Deployment implications
Production / staging servers: QM is not installed, so there is no conflict. HCQG_DB's db.php can be deployed directly.
Local development: If QM's db.php symlink is present, it must be removed (or renamed) before installing HCQG_DB's drop-in. QM loses its enhanced query panel but continues to function for other features. Restoring QM's symlink re-enables it.
WP-CLI installer (planned): The wp queryguard install-dropin command should detect an existing db.php, identify whether it belongs to QM, and warn the user — not silently overwrite it.
Acceptance criteria
Measured CPU overhead of 100% observation drops from ~10% to <1% on a representative WooCommerce request. Before/after numbers captured in a benchmark commit.
Pre-init queries (wp_load_alloptions(), auth/usermeta, WC session) are covered by MAX_EXECUTION_TIME in enforce mode — verified by killing an artificially slow autoload-option query in staging.
Behavior parity with v1: same log event names, same payload structure, same admin-search fallback, same tiered limits. Existing tests pass; new tests added for the drop-in's conditional-backtrace logic and first-query gating.
Safe activation / deactivation. db.php is a drop-in, not a plugin — it persists when the mu-plugin is removed, and the mu-plugin can be removed without breaking the site. Define the contract for both directions:
mu-plugin removed, db.php remains → drop-in degrades gracefully (no fatal, no leftover SET SESSION)
Documented rollback path. A site admin needs a one-step way to disable the drop-in without losing v1 protection.
Rollout question (needs a decision before implementation)
Two viable packaging models:
Option A — Ship inside this plugin, installer copies/symlinks to wp-content/db.php
Pro: single repo, single version, mu-plugin and drop-in are guaranteed to be in sync.
Con: requires an installer step (composer post-install, WP-CLI helper, or manual instruction). Drop-in deployment is sticky — uninstalling the mu-plugin leaves the drop-in unless the installer handles teardown.
Option B — Ship as a separate companion package
Pro: clean separation, drop-in lifecycle decoupled from mu-plugin.
Con: two repos to version in lockstep, two deployment steps, easier to end up with mismatched versions in production.
Recommendation pending — leaning toward Option A with a wp queryguard install-dropin / wp queryguard remove-dropin WP-CLI pair to manage the symlink/copy and detect drift.
Problem
Two independent v1 limitations share a single root cause: the plugin hooks
initat priority 1 and relies on WordPress core'sSAVEQUERIESconstant. Awp-content/db.phpdrop-in resolves both.1. Early-query coverage gap
v1 hooks
initat priority 1, so queries fired before then run without aMAX_EXECUTION_TIMEceiling:wp_load_alloptions()(autoloaded options)muplugins_loaded,plugins_loaded,setup_themeOn sites where these early queries are the ones that go runaway (bloated autoload table, cold object-cache miss, slow
alloptionson a managed-host MySQL hiccup), v1 enforce mode provides no protection. Documented inhypercart-query-guard.phpheader lines 21–27.Signs you need v2 (Noel's framing from the original issue):
context: frontendand alast_queryagainstwp_optionsalloptionsslow-query warnings in observe modeplugins_loaded2. SAVEQUERIES observation overhead
WordPress core's
SAVEQUERIEScallsdebug_backtrace()on every query unconditionally — before checking duration, before anything. The order insidewpdb::query():The plugin's shutdown handler then walks
$wpdb->queriesand only logs queries over the warn threshold (5s). But by that point, the cost has already been paid on every query.Measured cost on a WooCommerce page (~300 queries, ~300ms PHP CPU time, ~60MB memory — from
query-guard-findings.md):Site-wide CPU overhead scales linearly with sample rate:
This ~10% at 100% sampling is what's blocking the deployment plan: observer mode at 100% prior to enforce mode at 100% is the safe rollout sequence on busy production sites, but the SAVEQUERIES cost makes 100% observation unaffordable.
The irony: query duration is already known at step 3 before the backtrace runs at step 4. Core just doesn't check it. A conditional backtrace — time every query cheaply, only backtrace the slow ones — would reduce observation overhead to near zero. But there is no hook between "duration known" and "backtrace called", and SAVEQUERIES is a blunt on/off switch.
Solution
A
wp-content/db.phpdrop-in that extendswpdband overrides the query path to:1. Apply
SET SESSION MAX_EXECUTION_TIMEat connection timeInside
wpdb::db_connect()(or a first-query gate), so the limit is in place beforewp_load_alloptions()and the rest of the pre-initquery traffic. No hook dependency.2. Implement conditional backtracing
Time every query (microsecond cost —
wpdbdoes this anyway). Only calldebug_backtrace()whenduration >= warn_threshold. This replaces SAVEQUERIES with a targeted mechanism that has near-zero overhead on fast queries.3. Retain all v1 behavior
off/observe/enforce)$wpdb->last_errorHypercart_Logger(witherror_log()fallback)Query Monitor db.php conflict
WordPress only supports a single
wp-content/db.phpdrop-in. Query Monitor (QM) also ships adb.phpthat extendswpdb. Investigation of QM's implementation revealed the two drop-ins are mutually exclusive by design — they cannot be combined.What QM's db.php does
QM's
QM_DBclass (query-monitor/classes/DB.php) extendswpdband overridesquery(). Afterparent::query()returns, it replaces the caller string that core stored in$wpdb->queries[$i]with a fullQM_Backtraceobject (richer stack trace data for QM's developer panel). It also stores the query result or error for display in the panel.QM uses — not avoids — the SAVEQUERIES overhead
QM's db.php forces SAVEQUERIES on (line 85–87 of
query-monitor/wp-content/db.php):QM requires every query to be stored in
$wpdb->queriesby core. Then QM'squery()override augments each entry with its ownQM_Backtraceobject on top of thedebug_backtrace()that core already ran. The backtrace cost is effectively paid twice per query.The QM author acknowledges the limitation in a comment inside
QM_DB::query():Why combining is not viable
The two drop-ins have opposite goals:
QM_DB)HCQG_DB)QM needs
SAVEQUERIES = trueand unconditional backtracing to populate its developer panel. HCQG_DB exists specifically to eliminate that overhead. ExtendingQM_DBinstead ofwpdbwould defeat the purpose of the v2 drop-in entirely.Deployment implications
db.phpcan be deployed directly.db.phpsymlink is present, it must be removed (or renamed) before installing HCQG_DB's drop-in. QM loses its enhanced query panel but continues to function for other features. Restoring QM's symlink re-enables it.wp queryguard install-dropincommand should detect an existingdb.php, identify whether it belongs to QM, and warn the user — not silently overwrite it.Acceptance criteria
initqueries (wp_load_alloptions(), auth/usermeta, WC session) are covered byMAX_EXECUTION_TIMEinenforcemode — verified by killing an artificially slow autoload-option query in staging.db.phpis a drop-in, not a plugin — it persists when the mu-plugin is removed, and the mu-plugin can be removed without breaking the site. Define the contract for both directions:SET SESSION)initonwardRollout question (needs a decision before implementation)
Two viable packaging models:
Option A — Ship inside this plugin, installer copies/symlinks to
wp-content/db.phpOption B — Ship as a separate companion package
Recommendation pending — leaning toward Option A with a
wp queryguard install-dropin/wp queryguard remove-dropinWP-CLI pair to manage the symlink/copy and detect drift.Out of scope (handled elsewhere or punted)
References
hypercart-query-guard.phpheader lines 21–27 (original v2 note)query-guard-findings.mdlines 122–226 (Norman's Nursery field report — SAVEQUERIES overhead analysis, proposed solution, motivation)