PluginProbe
404 Solution / trunk
404 Solution vtrunk
4.3.5 4.3.4 4.3.3 4.3.2 4.3.1 4.3.0 4.2.0 4.1.19 4.1.18 4.1.17 4.1.16 4.1.15 4.1.13 4.1.12 4.1.11 4.1.10 4.1.9 4.1.8 4.1.7 4.1.6 4.1.5 4.1.4 4.1.3 trunk 2.30.0 All 109 releases
404-solution / includes / feedback / SupportEvidenceExcerpt.php

SupportEvidenceExcerpt.php in 404 Solution trunk, at includes/feedback/SupportEvidenceExcerpt.php

487 lines 24.7 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 if (!defined('ABSPATH')) {
4 exit;
5 }
6
7 /**
8 * Everything a support report carries in its `debug_log_excerpt` field, and
9 * the byte contract that field has to stay inside.
10 *
11 * Seven independent sources feed one string: a manifest of what the collector
12 * looked for, the detach A/B experiment's verdict for the session that
13 * clicked, the interpretation reconstructed from per-step canary receipts,
14 * the per-failing-session diagnostics for the session(s) that actually failed
15 * (ABJ_404_Solution_FailingSessionSupportSection), the sanitized debug-log
16 * tail, the two durable AJAX diagnostic journals, and the browser's own
17 * drained transport buffer. Deciding which of those a report
18 * carries, in what order, and how the sum stays under the wire contract is a
19 * different job from answering an AJAX request, and it is the job with the
20 * interesting failure modes: every one of beta.1's evidence losses happened
21 * here, not in the endpoint.
22 *
23 * Four ordering rules are load-bearing rather than cosmetic:
24 *
25 * 1. The collection manifest goes FIRST, because bound() cuts the tail. The
26 * one section that must survive a saturated payload is the one that says
27 * what was looked for.
28 * 2. The detach A/B verdict follows, ahead of every evidence section, for
29 * the same reason: it is the conclusion drawn FROM that evidence, and a
30 * conclusion that gets cut off the end of a busy session's payload is
31 * exactly the manual join it exists to replace.
32 * 3. The receipt-derived canary interpretation follows both independent
33 * conclusions' source ordering: it is derived from the journals but must
34 * survive any raw-evidence tail clamp.
35 * 4. The per-failing-session diagnostics follow the click-session verdict,
36 * still ahead of the evidence: they are the same experiment's conclusion
37 * computed for the session(s) that actually failed rather than the tab
38 * that clicked, and they state whether those are even the same session.
39 * 5. The journals follow in read order, so a reader walks the session the
40 * same way the journals were written.
41 *
42 * The class takes the browser's own inputs as an argument rather than reading
43 * $_POST: the request boundary belongs to the handler, and passing them in is
44 * what lets the same assembly run from anywhere (the report preview, a future
45 * CLI dump) without a fabricated superglobal.
46 */
47 final class ABJ_404_Solution_SupportEvidenceExcerpt {
48
49 /**
50 * The report contract's own bound on debug_log_excerpt
51 * (contracts/schemas/report.schema.json, maxLength). Every section written
52 * into that field is bounded so their sum provably fits underneath this,
53 * which is what stops a bigger diagnostic budget from turning "the journal
54 * reader dropped the evidence" into "the endpoint rejected the payload".
55 * SupportExcerptBudgetContractTest proves the arithmetic; the clamp in
56 * bound() is the backstop that makes it unconditional.
57 */
58 const MAX_DEBUG_LOG_EXCERPT_BYTES = 262144;
59
60 /**
61 * Hard cap on the sanitized debug-log tail. It is assembled from a bounded
62 * NUMBER of entries (15 errors plus 20 recent lines), not a bounded number
63 * of BYTES, so a single site that logs a large blob could otherwise push
64 * the assembled excerpt past the contract on its own.
65 */
66 const MAX_LOGGER_EXCERPT_LENGTH = 12288;
67
68 /**
69 * Hard cap on the drained client transport telemetry. The buffer is bounded
70 * on the browser side too; this is the server refusing to append more than
71 * that to the report regardless of what arrives.
72 */
73 const MAX_CLIENT_TELEMETRY_LENGTH = 32768;
74
75 /**
76 * Hard cap on the always-present collection manifest. Small on purpose: it
77 * describes the read rather than carrying evidence, and it must never be
78 * able to crowd out the evidence it describes.
79 * ABJ_404_Solution_DiagnosticCollectionManifest sheds detail to fit this
80 * instead of being cut, so an over-budget manifest still states its counts.
81 */
82 const MAX_COLLECTION_MANIFEST_BYTES = 8192;
83
84 /**
85 * The whole excerpt, ready for the payload.
86 *
87 * The browser's two contributions arrive as one named bag rather than as
88 * two positional strings: both are opaque browser-supplied text, and a
89 * swapped pair would silently produce a verdict about a session id that is
90 * really a telemetry buffer while reporting the buffer as unparseable.
91 *
92 * @param array{telemetry?: string, session_id?: string} $client
93 * telemetry: the drained attempt buffer, already unslashed.
94 * session_id: the browser session id this request was sent from, which is
95 * what the detach A/B verdict is scoped to.
96 * @return string
97 */
98 public static function assemble(array $client): string {
99 $clientTelemetry = self::clientField($client, 'telemetry');
100 $clientAttempts = ABJ_404_Solution_ClientTransportReport::attemptOutcomesInDrainedBuffer(
101 $clientTelemetry);
102 $clientSessionId = self::clientField($client, 'session_id');
103 $channels = self::collectChannels($clientAttempts);
104 // Ordered by what must survive bound(), which cuts from the END.
105 //
106 // The client transport buffer is placed HERE, ahead of the bulk
107 // sections, and that position is load-bearing rather than cosmetic. It
108 // used to be appended after everything else, which made it the first
109 // thing the truncation discarded -- and it is the one channel that
110 // carries attempts the server never saw at all, the exact evidence
111 // beta.1 came back without. Observed on 2026-08-15: an excerpt
112 // assembled at 1,095,170 bytes was cut to the 256 KB contract bound and
113 // arrived with the whole telemetry block gone, on a report whose
114 // storage had failed, which is precisely when that block is the only
115 // account of what the browser did.
116 //
117 // It is also the smallest of the high-value sections and the only one
118 // whose size the plugin does not choose, so spending its bytes first
119 // costs the report almost nothing. What now absorbs the cut is
120 // loggerExcerpt() and the per-channel bulk below it: large, generic,
121 // and reconstructible from the site's own debug log.
122 $sections = array(
123 self::collectionManifest($channels, $clientAttempts),
124 self::clientTransportTelemetrySection($clientTelemetry),
125 self::detachAbVerdict($clientSessionId),
126 self::canonicalSuppression(),
127 self::canaryReceiptInterpretation($clientSessionId),
128 self::failingSessionDiagnostics($clientAttempts, $clientSessionId),
129 self::strandedRequestDiagnostics(),
130 self::loggerExcerpt(),
131 );
132 foreach ($channels as $channel) {
133 $sections[] = $channel['collected'];
134 }
135 return self::bound(self::joinSections($sections));
136 }
137
138 /**
139 * One field of the client bag as a string, or '' when it is absent or not
140 * scalar. Assembly is total by construction: a caller that omits a field
141 * gets the section that field feeds saying so, never a type error inside a
142 * support request the admin is waiting on.
143 *
144 * @param array{telemetry?: string, session_id?: string} $client
145 */
146 private static function clientField(array $client, string $field): string {
147 $value = $client[$field] ?? null;
148 return is_scalar($value) ? (string)$value : '';
149 }
150
151 /**
152 * Both durable AJAX diagnostic journals: what each one was asked for, and
153 * what it gave back.
154 *
155 * The stage trace and the checkpoint log are separate channels on purpose
156 * (a defect in the trace must not be able to erase the evidence about it),
157 * so draining only one of them reintroduces the beta.1 failure mode from
158 * the read side: a request that never reached its first stage writes
159 * nothing to the trace, and its whole story lives in the checkpoints. Each
160 * is labeled and independently bounded, so a failure to read one still
161 * yields the other.
162 *
163 * The candidate paths come back paired with the text they produced,
164 * because the manifest below has to describe the read that actually
165 * happened, not a second guess at what it would have read.
166 *
167 * Every channel is sourced BEFORE any of them is read, because the two
168 * excerpts are ranked from one shared failure index and the index has to
169 * be complete before the first read spends its budget. Browser verdicts
170 * normally arrive through the checkpoint journal, but a final timeout can
171 * survive only in the drained support-request buffer when neither its
172 * retry nor its report-only beacon reached PHP. Both browser channels are
173 * therefore unioned into the same index before either journal applies its
174 * file cap. Without this, the stage trace ranks a request PHP completed and
175 * the browser never received as ordinary healthy context -- and that
176 * request's stage timings are the evidence for where the response was
177 * built before it failed to arrive.
178 *
179 * @param array{status: string, ids: array<int, string>, records: int, outcomes: array<string, bool>} $clientAttempts
180 * @return array<int, array{channel: string, directory: string, usable: bool, paths: array<int, string>, collected: string, file_selection: array<string, mixed>}>
181 */
182 private static function collectChannels(array $clientAttempts): array {
183 $trace = class_exists('ABJ_404_Solution_AjaxRequestTrace')
184 ? ABJ_404_Solution_AjaxTraceJournal::supportCollectionSource() : null;
185 $checkpoints = class_exists('ABJ_404_Solution_CheckpointJournalReader')
186 ? ABJ_404_Solution_CheckpointJournalReader::supportCollectionSource() : null;
187
188 // Built per channel rather than from one merged path list: each
189 // channel's read is bounded by its own file count and byte allowance,
190 // and an index assembled over a merged list would silently drop the
191 // files that fell off the far end of a combined bound.
192 $failingIds = array();
193 if (class_exists('ABJ_404_Solution_DiagnosticJournalExcerpt')) {
194 foreach (array($trace, $checkpoints) as $source) {
195 if ($source !== null) {
196 $failingIds += ABJ_404_Solution_DiagnosticJournalExcerpt::failureIndex($source['paths']);
197 }
198 }
199 }
200 $clientOutcomes = isset($clientAttempts['outcomes']) && is_array($clientAttempts['outcomes'])
201 ? $clientAttempts['outcomes'] : array();
202 foreach ($clientOutcomes as $requestId => $healthy) {
203 if ($healthy === false) {
204 $failingIds[(string)$requestId] = true;
205 }
206 }
207
208 $channels = array();
209 if ($trace !== null) {
210 $selection = ABJ_404_Solution_DiagnosticJournalFileSelector::select(
211 $trace['paths'], $failingIds);
212 $trace['file_selection'] = $selection['manifest'];
213 $trace['collected'] = ABJ_404_Solution_AjaxTraceJournal::readRecentForSupport(
214 $failingIds, $selection);
215 $channels[] = $trace;
216 }
217 if ($checkpoints !== null) {
218 $selection = ABJ_404_Solution_DiagnosticJournalFileSelector::select(
219 $checkpoints['paths'], $failingIds);
220 $checkpoints['file_selection'] = $selection['manifest'];
221 $checkpoints['collected'] =
222 ABJ_404_Solution_CheckpointJournalReader::readRecentForSupport(
223 $failingIds, $selection);
224 $channels[] = $checkpoints;
225 }
226 return $channels;
227 }
228
229 /**
230 * The always-present manifest section.
231 *
232 * Reading both journals is not enough on its own, which is the other half
233 * of beta.1: they came back EMPTY and the payload could not say whether
234 * that meant "nothing was written", "the collector looked in the wrong
235 * place", or "the read regressed". So this goes out unconditionally.
236 *
237 * The manifest classes are guarded the same way the journals are, because
238 * a partially recovered install can be missing any plugin file (see the
239 * safe-autoloader work for error 18). A missing manifest class is reported
240 * in the payload rather than silently skipped: an absent manifest is
241 * exactly the ambiguity this section exists to remove.
242 *
243 * @param array<int, array{channel: string, directory: string, usable: bool, paths: array<int, string>, collected: string, file_selection: array<string, mixed>}> $channels
244 * @param array{status: string, ids: array<int, string>, records: int, outcomes: array<string, bool>} $clientAttempts
245 */
246 private static function collectionManifest(array $channels, array $clientAttempts): string {
247 if (!class_exists('ABJ_404_Solution_DiagnosticCollectionManifest')
248 || !class_exists('ABJ_404_Solution_ClientTransportReport')) {
249 return 'Diagnostic collection manifest unavailable: the manifest classes could not be loaded'
250 . ' on this install, so what the collector checked cannot be stated.';
251 }
252 return ABJ_404_Solution_DiagnosticCollectionManifest::compose(
253 $channels,
254 $clientAttempts,
255 self::MAX_COLLECTION_MANIFEST_BYTES
256 );
257 }
258
259 /**
260 * The detach A/B experiment's verdict for the session that clicked: the one
261 * conclusion computed for the tab that sent the report rather than for the
262 * session(s) that failed. Deciding it belongs to
263 * ABJ_404_Solution_DetachAbEvidence and rendering it inside a byte budget
264 * belongs to ABJ_404_Solution_DetachAbVerdictSupportSection, so this
265 * composer stays a section list rather than a grab bag of section bodies.
266 *
267 * Guarded here the same way every other section is: a support request is the
268 * last thing that may be blocked by its own diagnostics, and a corrupt
269 * install can be missing any plugin file (safe-autoloader work for error 18).
270 */
271 private static function detachAbVerdict(string $sessionId): string {
272 if (!class_exists('ABJ_404_Solution_DetachAbVerdictSupportSection')) {
273 return 'Detach A/B verdict unavailable: ABJ_404_Solution_DetachAbVerdictSupportSection'
274 . ' could not be loaded on this install, so the experiment was not decided here.';
275 }
276 return ABJ_404_Solution_DetachAbVerdictSupportSection::compose($sessionId);
277 }
278
279 /**
280 * Whether WordPress core would still have canonicalized the URLs this site
281 * is capturing, as observed on the site's own front end.
282 *
283 * Placed with the conclusions rather than the evidence, and ahead of every
284 * bulk section, because it is small, it is the answer to a question that
285 * otherwise requires writing to the site owner and asking them to run a
286 * command, and a conclusion cut off the end of a busy session's payload is
287 * exactly the round trip it exists to replace.
288 *
289 * Deciding it belongs to ABJ_404_Solution_CanonicalRedirectHookCensus and
290 * rendering it inside a byte budget belongs to
291 * ABJ_404_Solution_CanonicalSuppressionSupportSection, so this composer
292 * stays a section list rather than a grab bag of section bodies.
293 *
294 * Guarded here the same way every other section is: a support request is the
295 * last thing that may be blocked by its own diagnostics, and a corrupt
296 * install can be missing any plugin file (safe-autoloader work for error 18).
297 */
298 private static function canonicalSuppression(): string {
299 if (!class_exists('ABJ_404_Solution_CanonicalSuppressionSupportSection')) {
300 return 'Canonical hook census unavailable:'
301 . ' ABJ_404_Solution_CanonicalSuppressionSupportSection could not be loaded on this'
302 . ' install, so the census was not rendered here.';
303 }
304 return ABJ_404_Solution_CanonicalSuppressionSupportSection::compose();
305 }
306
307 /**
308 * The beta.3-compatible interpretation reconstructed from durable receipts.
309 *
310 * A partially recovered install can be missing the new section class while
311 * still retaining old journals. State that explicitly instead of allowing
312 * diagnostics to block the support request that reports the corrupt install.
313 */
314 private static function canaryReceiptInterpretation(string $sessionId): string {
315 if ($sessionId === '') {
316 return '';
317 }
318 if (!class_exists('ABJ_404_Solution_CanaryReceiptSupportSection')) {
319 return 'Canary receipt interpretation unavailable: '
320 . 'ABJ_404_Solution_CanaryReceiptSupportSection could not be loaded on this install.';
321 }
322 return ABJ_404_Solution_CanaryReceiptSupportSection::compose($sessionId);
323 }
324
325 /**
326 * The per-failing-session diagnostics section: the detach verdict and
327 * encoded-size basis for the session(s) that actually failed, not just the
328 * tab that clicked. The whole section -- sourcing the journals, deriving the
329 * failing sessions, computing each verdict, and rendering the bounded block
330 * -- lives in ABJ_404_Solution_FailingSessionSupportSection so this
331 * composer stays a section list rather than a grab bag of section bodies.
332 *
333 * Guarded here the same way every other section is: a support request is the
334 * last thing that may be blocked by its own diagnostics, and a corrupt
335 * install can be missing any plugin file (safe-autoloader work for error 18).
336 *
337 * @param array{status: string, ids: array<int, string>, records: int, outcomes: array<string, bool>} $clientAttempts
338 */
339 private static function failingSessionDiagnostics(array $clientAttempts, string $clientSessionId): string {
340 if (!class_exists('ABJ_404_Solution_FailingSessionSupportSection')) {
341 return 'Failing-session diagnostics unavailable: ABJ_404_Solution_FailingSessionSupportSection'
342 . ' could not be loaded on this install, so per-session verdicts were not computed here.';
343 }
344 return ABJ_404_Solution_FailingSessionSupportSection::compose($clientAttempts, $clientSessionId);
345 }
346
347 /**
348 * The stranded-request section: which workers are still in flight far past
349 * a plausible lifetime, and which lifecycle segment each was inside when it
350 * last managed to record one.
351 *
352 * Read from the registry rather than from a journal on purpose. Every other
353 * evidence section here is derived from byte-capped files that rotate
354 * against site traffic and are then sampled by a priority pass, so on a busy
355 * site the decisive record can be gone before the admin clicks send. This
356 * one cannot be: it is a bounded reading of live state taken at click time.
357 * See ABJ_404_Solution_StrandedRequestSupportSection for why that
358 * distinction is what this section exists for.
359 *
360 * Guarded here the same way every other section is: a support request is the
361 * last thing that may be blocked by its own diagnostics.
362 */
363 private static function strandedRequestDiagnostics(): string {
364 if (!class_exists('ABJ_404_Solution_StrandedRequestSupportSection')) {
365 return 'Stranded-request diagnostics unavailable: ABJ_404_Solution_StrandedRequestSupportSection'
366 . ' could not be loaded on this install, so in-flight worker state was not read here.';
367 }
368 return ABJ_404_Solution_StrandedRequestSupportSection::compose();
369 }
370
371 /**
372 * The sanitized debug-log tail, or a stated reason there is none. Both are
373 * sections of the payload: an absent debug log used to be an empty string,
374 * which reads identically to a log that exists and says nothing.
375 */
376 private static function loggerExcerpt(): string {
377 return ABJ_404_Solution_SupportLogExcerpt::resolve(
378 'Support request', self::MAX_LOGGER_EXCERPT_LENGTH);
379 }
380
381 /**
382 * Join the non-empty excerpt sections with a blank line between them.
383 *
384 * A lone section is returned byte-for-byte: trimming it here would
385 * silently change what the developer receives.
386 *
387 * @param array<int, string> $sections
388 * @return string
389 */
390 private static function joinSections(array $sections): string {
391 $present = array();
392 foreach ($sections as $section) {
393 if (is_string($section) && trim($section) !== '') {
394 $present[] = $section;
395 }
396 }
397 if (count($present) < 2) {
398 return $present === array() ? '' : $present[0];
399 }
400 $last = array_pop($present);
401 return implode("\n\n", array_map('rtrim', $present)) . "\n\n" . $last;
402 }
403
404 /**
405 * Append the browser's drained transport-attempt buffer.
406 *
407 * This is the only channel that carries attempts the server never saw at
408 * all: a request that never reached PHP leaves no server-side trace to
409 * pair with, and beta.1 came back with exactly that -- three client
410 * timeouts and no evidence. The records are transport measurements
411 * (timings, byte counts, readyState, protocol); they carry no URL, no SQL
412 * and no user text, which is why they can ride the same opt-in field as
413 * the sanitized log tail.
414 *
415 * Malformed input is reported rather than dropped: "the client sent
416 * something we could not parse" is itself a finding about the client.
417 *
418 * Over-budget input is reduced a RECORD at a time by ClientTransportReport
419 * rather than cut at a byte offset. The browser store holds more than this
420 * budget carries, and cutting the serialized array mid-record left invalid
421 * JSON -- so a busy session, which is exactly the interesting kind, used to
422 * deliver its whole client-side story as "unparseable".
423 */
424 private static function clientTransportTelemetrySection(string $raw): string {
425 return self::appendClientTransportTelemetry('', $raw);
426 }
427
428 private static function appendClientTransportTelemetry(string $excerpt, string $raw): string {
429 if ($raw === '') {
430 return $excerpt;
431 }
432 $bounded = ABJ_404_Solution_ClientTransportReport::boundDrainedBuffer(
433 $raw, self::MAX_CLIENT_TELEMETRY_LENGTH);
434 if (!$bounded['parsed']) {
435 $block = 'Client transport telemetry (unparseable, ' . $bounded['raw_length'] . ' bytes, '
436 . $bounded['error'] . "):\n" . substr($raw, 0, 500);
437 } else {
438 $label = $bounded['dropped'] > 0
439 ? 'Client transport telemetry (JSON, ' . $bounded['kept'] . ' of '
440 . ($bounded['kept'] + $bounded['dropped']) . ' attempts, failures kept first):'
441 : 'Client transport telemetry (JSON):';
442 $block = $label . "\n" . $bounded['json'];
443 }
444 return $excerpt === '' ? $block : rtrim($excerpt) . "\n\n" . $block;
445 }
446
447 /**
448 * Last line of defence on the report contract's maxLength.
449 *
450 * The section budgets are chosen to sum well under the bound, so this can
451 * only fire if one of them is later raised without the arithmetic being
452 * rechecked. It cuts rather than letting buildPayload() reject the whole
453 * report, and it says so in the payload instead of silently shortening it:
454 * a truncation nobody can see is how evidence gets lost in transit, which
455 * is the exact failure this whole path was rebuilt to prevent.
456 *
457 * It is the LAST step of assemble(), which is what makes it a backstop at
458 * all. The handler used to clamp the server sections and then append the
459 * client buffer afterwards, so the one section that arrives from outside
460 * the site -- the only one whose size the plugin does not choose -- was the
461 * one section the clamp could not see.
462 */
463 private static function bound(string $excerpt): string {
464 if (strlen($excerpt) <= self::MAX_DEBUG_LOG_EXCERPT_BYTES) {
465 return $excerpt;
466 }
467 $note = "\n\n[404 Solution] Support excerpt truncated from " . strlen($excerpt)
468 . ' bytes to fit the report contract.';
469 $kept = substr($excerpt, 0, self::MAX_DEBUG_LOG_EXCERPT_BYTES - strlen($note));
470
471 // Cut on a RECORD boundary, never mid-line. Most of what this carries
472 // is JSONL, so a byte-offset cut can leave a half-written record whose
473 // trailing brace makes it look complete to a reader, and the reader
474 // then fails on the whole excerpt rather than on the one line that was
475 // damaged. Observed as "SyntaxError: Unterminated string in JSON at
476 // position 7" against a payload whose final line had been sliced
477 // mid-string. Dropping the partial line costs one record; keeping it
478 // costs the parse.
479 $lastBreak = strrpos($kept, "\n");
480 if ($lastBreak !== false) {
481 $kept = substr($kept, 0, $lastBreak);
482 }
483
484 return $kept . $note;
485 }
486 }
487