Skip to content

fix(wake-briefing): scan the shared debug.log once at network scope, not per site - #3524

Merged
chubes4 merged 5 commits into
mainfrom
fix/3522-wake-md-network-dedupe
Sep 20, 2026
Merged

chubes4 merged 5 commits into
mainfrom
fix/3522-wake-md-network-dedupe

Conversation

@chubes4

@chubes4 chubes4 commented Sep 19, 2026

Copy link
Copy Markdown
Member

Fixes #3522

The bug

WordPress multisite shares one wp-content/debug.log file across every site — WP_CONTENT_DIR is network-wide and WakeBriefingTask::resolveDebugLogPath() never reads a per-site option. But gatherNetworkSignals() ran gatherSiteSignals() — which unconditionally called getPhpFatals() — once per blog under switch_to_blog(). On the live 11-site extrachill.com network this meant the identical fatal signature was scanned, parsed, and rendered 11 times, once per site line:

- **extrachill.com** — ⚠ 9 PHP fatal(s) in debug.log, top: "owner-reference.php: Cannot redeclare function ec_link_page_owner_compatibility…" ×8.
- **community.extrachill.com** — ⚠ 1 error(s) logged, top signature ×1: "TaskScheduler: queued task requires agent context". · ⚠ 9 PHP fatal(s) in debug.log, top: "owner-reference.php: Cannot redeclare function ec_link_page_owner_compatibility…" ×8.
- **shop.extrachill.com** — ⚠ 9 PHP fatal(s) in debug.log, top: "owner-reference.php: Cannot redeclare function ec_link_page_owner_compatibility…" ×8.
- **artist.extrachill.com** — ⚠ 1 error(s) logged, top signature ×1: "TaskScheduler: queued task requires agent context". · ⚠ 9 PHP fatal(s) in debug.log, top: "owner-reference.php: Cannot redeclare function ec_link_page_owner_compatibility…" ×8.
- **events.extrachill.com** — ⚠ 2 task type(s) failing repeatedly — `unknown` ×128, `extrachill_network_classify_post_terms` ×6. Investigate? · ⚠ 15 job(s) stuck in processing. · ⚠ 342 error(s) logged, top signature ×134: "Job marked as failed". · ⚠ 9 PHP fatal(s) in debug.log, top: "owner-reference.php: Cannot redeclare function ec_link_page_owner_compatibility…" ×8.
... (6 more sites, same fatal clause repeated on every line)

~1KB of a 2,389-byte always-on WAKE.md file was one repeated string — roughly 40%. The volume alone is bad; the signal loss is worse: events.extrachill.com genuinely has 342 logged errors, 15 stuck jobs, and 2 task types failing repeatedly (128 + 6 occurrences) — and that line read with the exact same visual weight as the ten duplicate-noise lines around it. The briefing's whole job is a 3-second glance at "anything red that happened recently"; repeating one signature 11× worked directly against that.

Note: the underlying fatal (ec_link_page_owner_compatibility redeclaration) is a separate, already-shipped fix (artist-platform v1.21.1). This PR is only about the briefing reporting one fatal eleven times.

The fix: network-scope scan, not post-hoc dedup

Two approaches were on the table:

  1. Post-hoc dedup — scan per-site as before, then detect signatures identical across all (or most) sites and collapse them.
  2. Network-scope scan — recognize that debug.log is structurally shared, not coincidentally identical, and scan it exactly once at network scope instead of once per site.

Chose (2). A same-vs-different threshold in approach (1) has no meaningful "different" case to guard against — the file is quite literally the same file regardless of which blog is switched-to, so it would always compare identical, on every run, forever. Approach (1) would also still pay the O(sites) cost of reading and parsing an up-to-5MB tail N times, just to discover N identical strings and throw N−1 of them away. Approach (2) fixes both the noise and the wasted I/O: getPhpFatals() now runs once per network briefing, not once per site.

Implementation: gatherSiteSignals() gains a $scan_fatals flag (default true, so single-site-scope behavior — the far more common non-multisite case — is untouched). gatherNetworkSignals() calls getPhpFatals() once before the per-site loop, hoists any result into a **network-wide** line, and passes $scan_fatals = false into each per-blog gatherSiteSignals() call so the identical scan never runs again inside the loop.

Threshold: none needed, by construction — see above. This isn't "hoist when N/N sites agree," it's "this fact was never a per-site fact to begin with."

Placement: the network-wide line renders first, ahead of the per-site lines. Rationale: it's the most network-wide-relevant fact and a reader scanning top-to-bottom should see it before wading into 11 site lines, several of which are otherwise quiet and omitted entirely.

Before / after

Before (11 identical clauses, shown truncated above) — events.extrachill.com's real signal buried under 10 copies of the same noise.

After:

- **network-wide** — ⚠ 9 PHP fatal(s) in debug.log, top: "owner-reference.php: Cannot redeclare function ec_link_page_owner_compatibility…" ×8.
- **community.extrachill.com** — ⚠ 1 error(s) logged, top signature ×1: "TaskScheduler: queued task requires agent context".
- **artist.extrachill.com** — ⚠ 1 error(s) logged, top signature ×1: "TaskScheduler: queued task requires agent context".
- **events.extrachill.com** — ⚠ 2 task type(s) failing repeatedly — `unknown` ×128, `extrachill_network_classify_post_terms` ×6. Investigate? · ⚠ 15 job(s) stuck in processing. · ⚠ 342 error(s) logged, top signature ×134: "Job marked as failed".

One shared fact stated once; events.extrachill.com's real signal now stands on its own with nothing competing for attention on the same line.

Verification

  • php -l on both touched files — clean.
  • No PHPUnit-suite (*Test.php) coverage exists for WakeBriefingTask; it's covered by dependency-free "smoke" scripts under tests/*-smoke.php (existing pattern — see wake-briefing-infra-signals-smoke.php, wake-briefing-credential-integrity-smoke.php). Added tests/wake-briefing-network-fatals-dedupe-smoke.php, run via php tests/wake-briefing-network-fatals-dedupe-smoke.php:
    • Builds a 3-site fixture (two quiet sites + one site with genuinely site-specific facts mirroring events.extrachill.com's shape) and a shared temp debug.log.
    • Asserts gatherNetworkSignals() produces exactly one **network-wide** line, that it renders first, that no per-site line repeats the fatal text, and that the genuinely site-specific facts still render correctly on their own site line.
    • Asserts single-site scope (gatherSiteSignals($since), the default) still includes fatals directly — regression guard for the non-multisite path.
    • 8/8 assertions pass.
  • Re-ran the two pre-existing wake-briefing-*-smoke.php scripts (infra signals: 13/13, credential integrity: 25/25) — both pass unchanged, confirming the $scan_fatals default preserves existing single-site behavior exactly.

Constraints followed

  • Worked only in this worktree, branch fix/3522-wake-md-network-dedupe.
  • No CHANGELOG.md edit, no version bump.
  • No release/deploy.

…not per site

WakeBriefingTask's gatherNetworkSignals() ran gatherSiteSignals() once per
blog under switch_to_blog(), and getPhpFatals() unconditionally scanned
wp-content/debug.log inside it. On an 11-site multisite, debug.log is a
single file shared by every site (WP_CONTENT_DIR and resolveDebugLogPath()
are both network-wide, never per-blog), so an identical fatal signature was
read, parsed, and reported 11 times — once per site line — while re-reading
the same up-to-5MB tail 11 times per run.

Root fix over post-hoc dedup: scan debug.log exactly once in
gatherNetworkSignals(), before the per-site loop, and hoist the result into
a single '**network-wide**' line rendered ahead of the per-site lines.
gatherSiteSignals() gains a $scan_fatals flag (default true, preserving
single-site-scope behavior) so the per-blog loop can opt out instead of
scanning-then-discarding. No per-site fact is lost — the fatal was never a
site-specific fact — and per-site lines keep their genuinely per-site
signals (job failures, stuck jobs, grouped errors) uncollapsed.

Adds a dedicated smoke test exercising gatherNetworkSignals() against a
3-site fixture: one hoisted network-wide line, no per-site repetition, and
a site with real facts (mirroring the events.extrachill.com signal-loss
example from the issue) still rendering on its own line.

Fixes #3522
…grouping

PHPStan level 7 reported 13 errors on this file, nearly all of the form
"always true" / "always false" / "unreachable statement" / "method unused".

Root cause was the `$flush = function () use ( &$current, ... )` closure.
PHPStan cannot soundly narrow a variable that is both reset inside a closure
literal and mutated by the surrounding loop after that literal, so it inferred
$current as permanently stuck at its closure-definition value. Every branch
downstream then looked reachable only through an always-null/always-zero
state, and normalizeFatal() looked unused because its only call site sat in
what the analyser had concluded was dead code.

Split into two ordinary passes: collect discrete (possibly multi-line) log
entries, then filter to the rolling window and group them. Every state
transition is now straight-line control flow that is both correct and
statically checkable. `return` inside the closure becomes `continue` in the
loop, and the redundant `0 === $total ||` guard is dropped since an empty
$groups already covers it.

Behavior is unchanged: identical signatures across sites still collapse to one
hoisted network-wide line, and a signature unique to one site still renders on
that site's line.

Verified: php -l clean; phpstan level 7 reports zero occurrences of the 13
original error classes on this file.
…er-narrowing

WordPress stubs declare WP_DEBUG_LOG as bool, so a direct constant reference
let PHPStan prove is_string() always false and mark the explicit-path branch
dead (function.impossibleType, booleanAnd.alwaysFalse x2, notIdentical.alwaysTrue).

At runtime the constant is genuinely either a bool or an explicit log path
string — handling both is the entire purpose of resolveDebugLogPath(). Reading
through constant() yields mixed and keeps the real contract statically
checkable without weakening it.

Behavior unchanged: a non-empty string path is returned; true/false/'' fall
through to the ini and WP_CONTENT_DIR fallbacks as before.
…ndant guard

constant() with a literal name is still constant-folded to the stub type, so
the previous attempt did not widen anything. Resolve the constant name through
a variable instead, which keeps the documented bool-or-path contract checkable
without suppressing the rule or editing shared stubs.

Also drops the is_string( WP_CONTENT_DIR ) guard: the stubs already type it as
string, making that check provably redundant rather than defensive.

Runtime behavior unchanged — a non-empty string path is returned, bool/empty
falls through to the ini and WP_CONTENT_DIR fallbacks.
…_LOG

WordPress copies WP_DEBUG_LOG (bool true or an explicit path) into the
error_log ini at boot (wp-includes/load.php:630-632). Reading that ini
covers both cases without referencing the constant, whose stub type is
bool and cannot express the documented string-path contract.

Drops the WP_DEBUG_LOG branch that PHPStan kept proving dead even through
constant() and a variable name. Behavior unchanged after WordPress has
loaded: a custom path still comes from ini, bool-true still lands on
WP_CONTENT_DIR/debug.log via the same ini or the fallback.
@chubes4
chubes4 merged commit 3bbfd72 into main Sep 20, 2026
30 checks passed
@chubes4
chubes4 deleted the fix/3522-wake-md-network-dedupe branch September 20, 2026 15:15
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

WAKE.md repeats one network-wide log signature once per site (11x on an 11-site network)

1 participant