WP Trace
Runtime tracing and causal execution analysis for WordPress — why did WordPress do this?
Install
The author publishes release zips, so WP-CLI can install straight from GitHub:
wp plugin install https://github.com/rafiahmedd/wp-trace/releases/download/v0.1.0/wp-trace.zipRuntime tracing and causal execution analysis for WordPress — "Chrome DevTools for the WordPress backend."
Most debugging tools show disconnected panels: a query log here, hooks there, errors somewhere else. WP Trace reconstructs the causal execution chain of a request — request → hooks → callbacks → files → SQL → HTTP → errors — so you can answer the question that actually matters:
Why did WordPress do this?
- Local-first: no account, no cloud, no telemetry — traces stay in your database
- Plugin-agnostic: works with any plugin/theme stack, no framework assumptions
- Coexists with Query Monitor (including its
db.phpdrop-in), WooCommerce, REST-heavy plugins - Zero runtime dependencies: no Composer, no npm on the server — ships as a self-contained build
Requirements
| WordPress | 6.4+ (developed and tested against 7.1) |
| PHP | 8.1+ |
| Database | MySQL 5.7+ / MariaDB 10.3+ (custom tables, dbDelta) |
| Capability | manage_options for UI/REST/CLI (filterable) |
Installation
From the ZIP — upload dist/wp-trace.zip through Plugins → Add New → Upload Plugin, then activate. That's it; the compiled UI ships inside the plugin.
From source:
git clone <repo> wp-content/plugins/wp-trace
cd wp-content/plugins/wp-trace/ui && npm install && npm run build
On activation, WP Trace creates its tables and schedules an hourly retention janitor. Tracing is disabled by default — enable it from the admin screen or CLI.
Quick start
- Open WP Trace in the admin menu.
- In Settings, pick a tracing mode (see below) and save.
- Browse your site, load the page you're debugging, hit a REST endpoint.
- Back in WP Trace, open the newest request from the list.
- Read the Overview (stats + findings), then jump into Timeline, Hooks, Database, HTTP or Errors. Export the trace as JSON when you need to share or archive it.
Tracing modes
| Mode | Captures | Overhead | Best for |
|---|---|---|---|
| Disabled | nothing (reads one option at boot) | none | production-ish default |
| Basic | request envelope (type/URI/timing/memory/status/user), SQL summary with callers, outgoing HTTP summary, PHP errors, hook fire tallies | ≈ free — uses WordPress' own counters and SAVEQUERIES; no monkey-patching |
always-on visibility |
| Detailed | everything in Basic plus the full causal tree: every hook callback with duration, priority, source file and owning plugin/theme; queries and HTTP calls tree-linked to the callback that caused them | ~15–25 % on very large stacks (50+ plugins, 100k+ hook fires); lower on typical sites | debugging sessions |
Guidance: run basic when you want passive visibility; flip to detailed when hunting a specific problem. wp trace enable --mode=detailed / disable makes this a one-liner.
The admin UI
- Requests — every traced request with type, method, URI, status, duration, query/HTTP/error counts, memory and truncation flag. Filter by type, slow (>500 ms), with-errors; optional 5-second live refresh. Row click opens the trace.
- Request detail
- Overview — stat cards (duration, queries, HTTP, callbacks, hook fires, errors, memory, user, WP/PHP version), detection findings with owning components, hottest hooks.
- Timeline — every event as an offset/duration bar across the whole request; indent shows nesting; click an event for its SQL, file:line, ownership, hook and priority. Raise the minimum-duration filter on huge traces.
- Hooks — per-hook aggregation: calls, total/average time, slowest callback and its owner.
- Database — every query with duration, owning plugin and caller.
- HTTP — outgoing requests with status, duration and response size.
- Errors — warnings/notices/deprecations/exceptions with file, line and owner; suppressed (
@) errors flagged.
- Settings — mode, retention, slow-query/slow-callback thresholds.
Reading ownership
Every event carries a resolved component: core, plugin <slug>, mu-plugin, theme, child-theme or unknown. A query executed inside plugins/foo/vendor/... is attributed to plugin foo — vendor paths resolve to their parent plugin. This is what turns "some query is slow" into "licenzo-pro runs a slow query from Validator.php:92".
Detection findings
Deterministic heuristics run at finalization and appear in the Overview (they're observations, never diagnoses):
| Finding | Trigger |
|---|---|
| Potential duplicate query | same normalized query shape ≥ 2 times (literals/whitespace-insensitive) |
| Potential slow query | query above the slow-query threshold (default 100 ms) |
| Potential duplicate HTTP request | same method + host + path ≥ 2 times |
| Potential slow callback | hook callback above threshold (default 250 ms) |
| Potential recursive hook chain | a hook re-entering itself through its callback ancestry (e.g. save_post → wp_update_post → save_post) |
| Repeated option writes | same option updated ≥ 3 times in one request |
Each finding links to the offending event sequences and names the owning components.
WP-CLI
wp trace status # mode, storage, schema, wpdb class, xdebug state
wp trace enable [--mode=basic|detailed] # default basic
wp trace disable
wp trace requests [--type=rest] [--limit=20] [--format=table|csv|json|ids]
wp trace inspect <id> [--section=summary|tree|events|hooks|queries|http|errors] [--format=table|json]
wp trace export <id> [--file=trace.json] # stable JSON format v1
wp trace clear [--force]
wp trace delete <id> [<id>...]
wp trace invocations are excluded from tracing themselves (no feedback loops).
REST API
All routes live under /wp-json/wp-trace/v1, use standard cookie+nonce auth and require manage_options (both capabilities are filterable). Requests to this namespace are not traced.
| Method | Route | Purpose |
|---|---|---|
| GET | /requests |
list — filters: type, method, status, slow (µs), errors, search, orderby, order, pagination |
| GET | /requests/{id} |
request row + summary + findings |
| DELETE | /requests/{id} |
delete one trace (manage capability) |
| DELETE | /requests |
delete all (manage capability) |
| GET | /requests/{id}/events |
flat events — type, per_page |
| GET | /requests/{id}/tree |
nested causal tree — min_duration_us pruning |
| GET | /requests/{id}/export |
trace JSON v1 download (manage capability) |
| GET / POST | /settings |
read / update settings |
Export format
wp trace export and the UI's Export JSON emit a stable, self-contained trace (version 1):
{
"version": 1,
"generator": "wp-trace",
"request": { "type": "rest", "method": "POST", "uri": "/wp-json/learner-lms/v1/courses", "duration_us": 842000, "…": "…" },
"summary": { "counters": { "callback": 326 }, "findings": [ "…" ] },
"events": [
{ "seq": 1, "parent_seq": 0, "type": "callback", "name": "CourseController::index",
"component": { "type": "plugin", "id": "learner-lms" }, "offset_us": 312500, "duration_us": 240000 }
]
}
The format is designed for bug reports, diffing, CI artifacts and future tooling.
Settings reference
| Setting | Default | Meaning |
|---|---|---|
mode |
disabled |
tracing mode |
retention_count |
100 | newest N requests kept (hourly janitor + batched trims) |
slow_query_ms |
100 | slow-query finding threshold |
slow_callback_ms |
250 | slow-callback finding threshold |
max_events |
20000 | global stored-event cap (query/HTTP/error events survive truncation via their own caps) |
max_queries / max_http / max_hook_spans |
2000 / 500 / 3000 | per-type caps |
min_callback_us |
100 | callbacks faster than this are counted but not stored — the main performance dial |
memory_guard_pct |
80 | at 80 % of memory_limit the collector degrades to counting-only |
Kill switches: WP_TRACE_DISABLE (constant, removes all hooks), WP_TRACE_MODE (constant, overrides the option), WP_TRACE=0 (environment, for CLI runs).
Privacy & security
- Traces contain sensitive runtime data by nature — they are gated behind
manage_optionseverywhere (UI, RESTpermission_callback, admin forms with nonces). - Never captured: request/response headers, cookies,
$_POST/$_FILESbodies, environment secrets. - Redacted before persistence: URI query parameters and any array key matching sensitive patterns (
password,token,api_key,auth,cookie,nonce, payment terms — configurable list). There is no "raw" copy to leak: storage only ever sees redacted data. - Storage is retention-bounded (default 100 requests) with one-click/CLI purge;
uninstall.phpdrops both tables and all options. - Strings and nesting depth in stored metadata are size-capped as a memory defense.
Performance notes
Finalization (sanitize → detect → batch insert) is bounded and self-profiled — every trace's summary includes finalize phase timings and the stored-row count, so you can see the cost on your own site. Measured on a worst-case lab (50 plugins, ~100k hook fires per request):
- disabled: no overhead beyond one autoloaded option read
- basic: within measurement noise
- detailed: finalizer ~170 ms; total page impact ~15–25 % / ~0.5 s on that stack, dominated by per-callback instrumentation cost
Tuning detailed mode for slower machines: raise min_callback_us (e.g. 250), lower max_events (e.g. 8000). A warning banner appears in traces recorded while Xdebug was active, since it inflates timings.
Compatibility & limitations
- Coverage starts at the plugin's own load: everything from
plugins_loadedonward is traced; core bootstrap, mu-plugin file loading and the pre-plugin option queries are not. - Works with Query Monitor's
db.phpdrop-in (traces layer on top ofQM_DB). Other custom database drop-ins (hyperdb-style) fall back to flatSAVEQUERIEScapture without query tree-linkage — the trace summary flags thiscompat_mode. - HTTP calls bypassing the WordPress HTTP API (raw cURL, Guzzle) are invisible;
pre_http_requestshort-circuits are recorded with approximate duration. - Deprecation notices only fire when
WP_DEBUGis enabled (WordPress behavior). - Multisite: tables are per-site (prefixed); tested on single site.
Uninstall
Deactivating keeps data (only the retention cron is cleared). Deleting the plugin through WordPress drops both tables and deletes all options — traces are destroyed with it.
Development
php bin/generate-classmap.php # regenerate includes/classmap.php after adding classes
php tests/run.php # WordPress-free unit tests
php tests/Integration/smoke.php # full pipeline against a real install (WP_TRACE_WP_ABSPATH=/path)
cd ui && npm install && npm run build # build the admin app → ui/build (shipped in the ZIP)
php bin/make-pot.php # regenerate languages/wp-trace.pot
./bin/build-zip.sh # build dist/wp-trace.zip
- PHP: namespaced OOP (
WPTrace\), one class per file undersrc/, PSR-4 layout with a generated classmap autoloader — no Composer at runtime. - UI: Vite + React (no runtime dependencies beyond React), hash routing, plain CSS. Enqueued only on the plugin screen; loads as an ES module.
- Architecture: full design with WordPress-source citations lives in docs/ARCHITECTURE.md; the product plan in docs/PLAN.md.
- Safety invariant: the tracer must never break the traced site — every integration point has a fallback path (wpdb swap verification + compat mode, capped counting-only collector, finalizer wrapped so failures are swallowed).
License
GPL-2.0-or-later, like WordPress itself.
Releases
1 release. Each count is every asset in that release; expand a row for the breakdown.