Repository navigation
Show the plugin's service registration in Query Monitor - #163
Merged
Merged
Conversation
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.
13 tasks
This was referenced Oct 23, 2026
Open
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.
6 tasks
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
ObservesRegistrationcapability observes the registration,ServiceBasedPluginappends an immutableRegistrationRecord(service ID, resolved real class, state, evidence) to a request-localRegistrationLogat 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 unchangedFailedToResolveDependencies). Services, including those built in scoped lifecycle graphs, receive the history only through the read-onlyRegistrationReport; the writer is never injectable. A custom log can be passed as a new optional trailing constructor argument; otherwise the injector makes aSimpleRegistrationLog, and only once a service observes. The bundled observer is the optionalRegistrationPanelinsrc/Infrastructure/QueryMonitor/: shipped but not declared, it is enabled by the single, documented composition-root entryself::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:ObservesRegistrationthe staticobserves_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.RegistrationPanel::query_monitor_runs(), the one place):class_exists( 'QueryMonitor', false )andclass_exists( 'QM_Collectors', false )without autoloading, noWP_Customize_Managerloaded, thenQM_Collectors::request_supported(). Verified against the 4.0.7 source:query-monitor.phpregisters its autoloader before itsQM_DISABLED, install, CLI, cron and plugin-update exits, and onlyQueryMonitor::init()at its end loadsQueryMonitor. The built-in overview collector loadsQM_Collectorsduringset_up(). Query Monitor only tells a Customizer preview apart fromsetup_themeon, 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_CollectorandQM_Output_Html, soRegistrationCollectorFactoryandRegistrationOutputFactorydeclare them as anonymous classes insidecreate(). Loading any file insrc/never needs Query Monitor (php -n scripts/check-bundled-autoloader.phploads all 81 declarations with no exclusion added). The panel invokes the factories only from Query Monitor's own callbacks.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 throughQM_Collectors::add()atshutdownpriority 0. That is before the HTML dispatcher prints at priority 9, and independent of when the one-timeqm/collectorspass runs. The output is keyed by the collector ID, so QM 4'sPhpPanelFallbackfinds#qm-{id}-containerfor the menu entryadmin_menu()adds.architecture.outputOnlyThroughViewsrequires. Every record field is escaped withesc_html().tests/php/Fixture/QueryMonitor/, inRepositoryLayout::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 noscanFileswere 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
Tests:
RegistrationRecordingTestcovers 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.RegistrationPanelTestruns 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; idempotentregister(); factory doubles; and the escaped hostile values with per-row assertions.QueryMonitorPanelTestcovers the real composition root with the entry added through the services filter.CompositionRootContractTestchecks the real configuration with the panel declared. A deliberate mutation (dropping oneesc_html) fails the panel test.Released Query Monitor verification (required before merge):
https://downloads.wordpress.org/plugin/query-monitor.4.0.7.zip(sha256dc13f0826f8ddc21887223845e0e06c18ec1518316f19223fb02ca49508a5cfa, the latest release on wordpress.org). WordPress 7.1.3 (wordpress:php8.3-apache) withmariadb:11via docker compose, installed withwordpress:cli-php8.3.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 throughmwpd.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 oninit), and an unneeded service.wptransitions andneeds-hostile: waiting for dependencies, then waiting withAwaited actions: init, then registered. Every hostile value appeared as literal text; the panel contained 0script/img/belements and no dialog or page error fired.SampleLoopServiceregisters atwp). Query Monitor inactive: page 200, no panel output. Customizer preview request: page 200, no panel.debug.logstayed empty. Containers torn down afterwards.Independent review (gpt-6-astra) findings and what happened to them:
ReadOnlyRegistrationReport.collect_missing_dependencies()→ partly fixed: pending services reuse whatcollect_awaited_actions()collected. One extra staticget_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 protecteddependencies_are_met()contract.Checklist
composer testandcomposer lintpass locally, and CI is green on the current head.breakingand 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.Anything left open
Nothing for the acceptance criteria. Known limits, documented in the pattern:
schedule_potential_service_registration()ormaybe_register_service()that does not call the parent records nothing for its services.