Skip to content

Show the plugin's service registration in Query Monitor - #163

Merged
schlessera merged 3 commits into
mainfrom
feat/38-query-monitor-panel
Oct 23, 2026
Merged

schlessera merged 3 commits into
mainfrom
feat/38-query-monitor-panel

Conversation

@schlessera

Copy link
Copy Markdown
Member

Closes #38

What changed

The plugin can now answer "why didn't this service run in this request?". While a configured service implementing the new ObservesRegistration capability observes the registration, ServiceBasedPlugin appends an immutable RegistrationRecord (service ID, resolved real class, state, evidence) to a request-local RegistrationLog at each decision it already makes: registered, skipped (not needed, or dropped because a dependency will not be registered), waiting for an action, waiting for dependencies (with the missing IDs and the awaited actions), and failed (recorded just before the unchanged FailedToResolveDependencies). Services, including those built in scoped lifecycle graphs, receive the history only through the read-only RegistrationReport; the writer is never injectable. A custom log can be passed as a new optional trailing constructor argument; otherwise the injector makes a SimpleRegistrationLog, and only once a service observes. The bundled observer is the optional RegistrationPanel in src/Infrastructure/QueryMonitor/: shipped but not declared, it is enabled by the single, documented composition-root entry self::REGISTRATION_PANEL_ID => RegistrationPanel::class, and only acts when Query Monitor actually runs. It shows each service's current state (derived from the history) followed by the full ordered history, server-rendered through a view (views/query-monitor-registration.php).

Why this shape

It follows the consolidated rulings on the issue: injected writer with a read-only report, full ordered history, QM_Collectors::add(), explicit opt-in, early disabled path, infrastructure placement and injected factories. Decisions made while implementing:

  • Enablement before capture. Right after the services are resolved once, and before any is scheduled, the plugin asks each declared class implementing ObservesRegistration the static observes_registration(). No service is built and no resolver runs again. When none says yes, nothing is allocated and every capture point is a single null check.
  • Availability check (RegistrationPanel::query_monitor_runs(), the one place): class_exists( 'QueryMonitor', false ) and class_exists( 'QM_Collectors', false ) without autoloading, no WP_Customize_Manager loaded, then QM_Collectors::request_supported(). Verified against the 4.0.7 source: query-monitor.php registers its autoloader before its QM_DISABLED, install, CLI, cron and plugin-update exits, and only QueryMonitor::init() at its end loads QueryMonitor. The built-in overview collector loads QM_Collectors during set_up(). Query Monitor only tells a Customizer preview apart from setup_theme on, after the plugin decides, so a request that loaded the Customizer counts as unsupported. As a side effect, the panel is also off on the Customizer screen itself.
  • QM-derived classes behind factories. The collector and output extend QM_Collector and QM_Output_Html, so RegistrationCollectorFactory and RegistrationOutputFactory declare them as anonymous classes inside create(). Loading any file in src/ never needs Query Monitor (php -n scripts/check-bundled-autoloader.php loads all 81 declarations with no exclusion added). The panel invokes the factories only from Query Monitor's own callbacks.
  • Registration timing. register() only adds hooks with stable callbacks, so it stays idempotent and needs no Query Monitor (the composition-root contract runs it twice with the panel declared). The collector joins through QM_Collectors::add() at shutdown priority 0. That is before the HTML dispatcher prints at priority 9, and independent of when the one-time qm/collectors pass runs. The output is keyed by the collector ID, so QM 4's PhpPanelFallback finds #qm-{id}-container for the menu entry admin_menu() adds.
  • Output through a view, as architecture.outputOnlyThroughViews requires. Every record field is escaped with esc_html().
  • Composition-root wiring is pre-configured but inert: two factory bindings and two arguments (prefixed panel slug, title). With the entry absent, no panel class is loaded and nothing is allocated (tested). The bindings make the factory and interface files load during injector validation; they are not Query Monitor-derived.
  • Analysis stand-ins. The tests' Query Monitor stand-ins (tests/php/Fixture/QueryMonitor/, in RepositoryLayout::RULE_FIXTURES) carry 4.0.7's signatures for the symbols used. PHPStan and Psalm analyse them with the tests, so no stub package and no scanFiles were needed. Psalm's WordPress plugin is told about the two Query Monitor filters through a hook file. No level, baseline or analysed path changed.

Proof

composer test     # PHP 8.3 (locked): OK, 575 tests; PHP 7.4 (composer update): OK, 5 pre-existing skips
composer lint     # phpcs, phpstan max, psalm 2, PHPStan rule tests: all pass
php -n scripts/check-bundled-autoloader.php   # 81 declarations, PHP 8.3 and 7.4 without vendor/

Tests: RegistrationRecordingTest covers each state and its evidence, order across services, append-only history, lazy services never built, the writer never injectable, the custom and default logs, and identical container, hooks, condition and resolver counts with recording on and off. RegistrationPanelTest runs in separate processes and covers: absent, installed but not running (stand-in autoloader proves nothing was requested), unsupported, iframed and Customizer requests; panel declared without Query Monitor; Query Monitor running without the entry; both plugin load orders; idempotent register(); factory doubles; and the escaped hostile values with per-row assertions. QueryMonitorPanelTest covers the real composition root with the entry added through the services filter. CompositionRootContractTest checks the real configuration with the panel declared. A deliberate mutation (dropping one esc_html) fails the panel test.

Released Query Monitor verification (required before merge):

  • Query Monitor 4.0.7 from https://downloads.wordpress.org/plugin/query-monitor.4.0.7.zip (sha256 dc13f0826f8ddc21887223845e0e06c18ec1518316f19223fb02ca49508a5cfa, the latest release on wordpress.org). WordPress 7.1.3 (wordpress:php8.3-apache) with mariadb:11 via docker compose, installed with wordpress:cli-php8.3.
  • The branch was mounted read-only as the plugin, without vendor/, so the bundled autoloader served it and no build step ran. The only change was the documented composition-root entry, uncommented. A must-use plugin added services through mwpd.basic_scaffold.services: ID <script>alert("id")</script> delayed on action <img src=x onerror="alert(1)">, a service depending on <b>hostile-dependency</b> (delayed on init), and an unneeded service.
  • Logged in as admin in headless Chromium, opened Query Monitor from the admin bar and clicked the new Basic Scaffold services menu entry. QM's React panel showed the server-rendered tables: current state for 7 services, then the 11-record history in order. The history includes the samples' skip/wait-for-wp transitions and needs-hostile: waiting for dependencies, then waiting with Awaited actions: init, then registered. Every hostile value appeared as literal text; the panel contained 0 script/img/b elements and no dialog or page error fired.
  • Checked with Query Monitor loaded before the plugin and with the plugin loaded first (front page and a singular page, where SampleLoopService registers at wp). Query Monitor inactive: page 200, no panel output. Customizer preview request: page 200, no panel. debug.log stayed empty. Containers torn down afterwards.

Independent review (gpt-6-astra) findings and what happened to them:

  • The writer was reachable by inspecting the injected object → fixed: services get ReadOnlyRegistrationReport.
  • Scoped lifecycle graphs could not inject the report → fixed, with a test.
  • A Customizer preview turned recording on before QM rejected it → fixed, with a test.
  • Recording re-ran collect_missing_dependencies() → partly fixed: pending services reuse what collect_awaited_actions() collected. One extra static get_dependencies() read remains for a service set aside in the first pass, and only while observing. It reads no condition or resolver, so I kept it rather than change the protected dependencies_are_met() contract.
  • Weak row assertions and an unchecked hook priority → fixed.

Checklist

  • composer test and composer lint pass locally, and CI is green on the current head.
  • Every acceptance criterion from the issue is met, or the gap is named below.
  • Nothing a generated plugin relies on changed, or the issue carries breaking and a ruling. Additive only: new types, one optional trailing constructor parameter ($registration_log, sixth, after infrastructure: lifecycle interfaces for multisite and uninstall #34's), as ruled. The API snapshot was regenerated; its only changed line is that constructor signature.
  • Docs and inline comments reflect the change, or nothing documented moved. Added a "I need to see why a service did not run" pattern with the exact entry, disabled behaviour, cost, supported release and the override caveat. Also updated the design-principles capability table, the architecture rules, the README and AGENTS.md.

Anything left open

Nothing for the acceptance criteria. Known limits, documented in the pattern:

  • An override of schedule_potential_service_registration() or maybe_register_service() that does not call the parent records nothing for its services.
  • The panel is also off on the Customizer screen.

The plugin records each registration decision it makes, as it makes it,
while a configured service implementing ObservesRegistration observes the
registration. Services receive the history through the read-only
RegistrationReport; the RegistrationLog writer stays with the plugin, and
a custom log can be passed as a new optional trailing constructor argument.
Without an observer, no log or record is created.

The optional RegistrationPanel in src/Infrastructure/QueryMonitor/ is such
an observer: declared in the composition root and with Query Monitor
running, it lists every service with its class, current state and evidence,
followed by the full history. Its collector and output extend Query Monitor
classes, so injected factories declare them as anonymous classes and only
create them once Query Monitor asks for the panel.
…izer

Address the independent review: services now receive a wrapper that cannot
write instead of the log itself, scoped lifecycle graphs receive the report
too, recording pending services reuses the dependencies the plugin already
collected, and a Customizer request, which Query Monitor only rejects after
the plugin has decided, no longer turns recording on.
@schlessera schlessera added type: feat A new capability or a change in behaviour. area: infrastructure src/Infrastructure: services, injector, views, autoloader. What generated plugins build on. labels Oct 23, 2026
@schlessera
schlessera merged commit 1254225 into main Oct 23, 2026
5 checks passed
schlessera added a commit to mwpd/content-freshness that referenced this pull request Oct 23, 2026
Ported from mwpd/basic-scaffold@1254225 (mwpd/basic-scaffold#163).

The panel stays optional and undeclared; its test declares it and reads the plugin's four services.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area: infrastructure src/Infrastructure: services, injector, views, autoloader. What generated plugins build on. type: feat A new capability or a change in behaviour.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

dx: show the plugin's services in Query Monitor

1 participant