| 1 |
<?php |
| 2 |
|
| 3 |
if (!defined('ABSPATH')) { |
| 4 |
exit; |
| 5 |
} |
| 6 |
|
| 7 |
/** |
| 8 |
* What the browser said about which SERVER requests failed. |
| 9 |
* |
| 10 |
* Only the browser can see a request that produced no response at all: PHP can |
| 11 |
* run a table request to completion, write a full lifecycle, exit cleanly, and |
| 12 |
* the response can still never arrive. Nothing on the server side distinguishes |
| 13 |
* that request from healthy traffic, so the browser's account of it is the most |
| 14 |
* authoritative failure signal the diagnostics have. |
| 15 |
* |
| 16 |
* That account always arrives on a DIFFERENT request than the one it is about, |
| 17 |
* by two routes: |
| 18 |
* |
| 19 |
* 1. `client_prior_attempt` -- the browser attaches its record of the |
| 20 |
* previous attempt to the following table request, so a verdict about |
| 21 |
* attempt N rides attempt N+1's envelope. |
| 22 |
* 2. `client_report_only_branch` -- a `sendBeacon` delivery, which is a |
| 23 |
* second HTTP request whose only purpose is to talk about a first one that |
| 24 |
* failed. It travels under its own carrier id and names the attempt it |
| 25 |
* reports on in `reported_attempt_id`. |
| 26 |
* |
| 27 |
* Resolving "which request id does this record condemn" is therefore its own |
| 28 |
* decision, and it lives here rather than inside a ranking class because two |
| 29 |
* unrelated callers need it: the per-journal ranking |
| 30 |
* (ABJ_404_Solution_DiagnosticEvidencePriority) folds each verdict into the |
| 31 |
* condemned request's group, while ABJ_404_Solution_DiagnosticJournalExcerpt |
| 32 |
* builds a CROSS-journal index from it and wants no grouping, no ranking and no |
| 33 |
* budget at all. One extractor for both is the point: the browser writes its |
| 34 |
* verdicts to one journal only, and two extractors would be two chances for the |
| 35 |
* two journals to disagree about which requests failed. |
| 36 |
* |
| 37 |
* This class reads no files, holds no budget policy, and formats nothing. |
| 38 |
*/ |
| 39 |
final class ABJ_404_Solution_DiagnosticClientVerdict { |
| 40 |
|
| 41 |
/** |
| 42 |
* The browser's account of an EARLIER attempt, journaled against whichever |
| 43 |
* request carried it here. |
| 44 |
*/ |
| 45 |
const PRIOR_ATTEMPT_EVENT = 'client_prior_attempt'; |
| 46 |
|
| 47 |
/** |
| 48 |
* The branch a report-only beacon takes. A beacon is sent only after the |
| 49 |
* FINAL attempt of a request has failed, so the attempt it names on its |
| 50 |
* envelope is condemned by the beacon's mere existence -- which is what |
| 51 |
* keeps the verdict readable when the report it carried arrives truncated |
| 52 |
* or unparseable and the id inside it is gone. |
| 53 |
*/ |
| 54 |
const BEACON_BRANCH_EVENT = 'client_report_only_branch'; |
| 55 |
|
| 56 |
/** |
| 57 |
* The only client-reported outcome that means an attempt did not fail. |
| 58 |
* |
| 59 |
* Deliberately a one-element allowlist rather than a deny-list of known |
| 60 |
* failure words. 'pending' is an attempt that never finished, which is the |
| 61 |
* hung request itself; an absent or unrecognised outcome is an unknown, |
| 62 |
* and an unknown is more interesting than a known success. Over-including |
| 63 |
* evidence costs budget; under-including it costs the whole investigation. |
| 64 |
*/ |
| 65 |
const HEALTHY_OUTCOMES = array('success'); |
| 66 |
|
| 67 |
/** |
| 68 |
* The request id one journal record condemns, or '' when it condemns none. |
| 69 |
* |
| 70 |
* @param array<array-key, mixed> $record One decoded JSONL record. |
| 71 |
*/ |
| 72 |
public static function condemnedRequestId(array $record): string { |
| 73 |
$reported = self::reportedOutcome($record); |
| 74 |
return $reported['ok'] ? '' : $reported['id']; |
| 75 |
} |
| 76 |
|
| 77 |
/** |
| 78 |
* What one journal record says about the attempt it names, or an empty id |
| 79 |
* when it names none. |
| 80 |
* |
| 81 |
* Split out of condemnedRequestId() because two callers need opposite |
| 82 |
* halves of the same answer: ranking only cares which attempts FAILED, |
| 83 |
* while the detach A/B verdict (ABJ_404_Solution_DetachAbEvidence) needs |
| 84 |
* the successes too -- an experiment that separates "ON completed" from |
| 85 |
* "OFF did not" cannot be decided from failures alone. Resolved once here |
| 86 |
* so the two can never disagree about which attempt a report is about. |
| 87 |
* |
| 88 |
* @param array<array-key, mixed> $record One decoded JSONL record. |
| 89 |
* @return array{id: string, ok: bool} ok is true only for an outcome that |
| 90 |
* positively means the attempt completed. |
| 91 |
*/ |
| 92 |
public static function reportedOutcome(array $record): array { |
| 93 |
$event = isset($record['event']) && is_scalar($record['event']) ? (string)$record['event'] : ''; |
| 94 |
if ($event === self::PRIOR_ATTEMPT_EVENT) { |
| 95 |
if (!isset($record['report']) || !is_array($record['report'])) { |
| 96 |
return array('id' => '', 'ok' => false); |
| 97 |
} |
| 98 |
$report = $record['report']; |
| 99 |
$outcome = isset($report['outcome']) && is_scalar($report['outcome']) |
| 100 |
? (string)$report['outcome'] : ''; |
| 101 |
return array( |
| 102 |
'id' => self::journalKeyOfReportedAttempt($report), |
| 103 |
'ok' => in_array($outcome, self::HEALTHY_OUTCOMES, true), |
| 104 |
); |
| 105 |
} |
| 106 |
if ($event === self::BEACON_BRANCH_EVENT) { |
| 107 |
// No outcome to weigh: the beacon fires only from the transport's |
| 108 |
// terminal-error path, so it exists only because the attempt it |
| 109 |
// names failed. Older clients name nothing and condemn nothing -- |
| 110 |
// their beacon still rides the attempt's own id, and the report it |
| 111 |
// carries is the verdict, exactly as before. |
| 112 |
return array('id' => self::journalKeyOf($record['reported_attempt_id'] ?? null), 'ok' => false); |
| 113 |
} |
| 114 |
return array('id' => '', 'ok' => false); |
| 115 |
} |
| 116 |
|
| 117 |
/** |
| 118 |
* Every attempt the browser reported on anywhere in a stream of records, |
| 119 |
* keyed by the journal key that attempt was recorded under. |
| 120 |
* |
| 121 |
* An attempt absent from the returned map is one the browser has not |
| 122 |
* spoken about, which is a different thing from one it reported as failed: |
| 123 |
* callers that tally outcomes must treat the absence as unknown, never as |
| 124 |
* a failure. A failure, once reported, is sticky -- a later duplicate or |
| 125 |
* reordered report cannot un-fail an attempt, and between two reports the |
| 126 |
* failing one is always the more interesting finding. |
| 127 |
* |
| 128 |
* @param array<int, string> $lines JSONL lines, any order. |
| 129 |
* @return array<string, bool> true = the browser saw that attempt complete. |
| 130 |
*/ |
| 131 |
public static function reportedOutcomesIn(array $lines): array { |
| 132 |
$outcomes = array(); |
| 133 |
foreach ($lines as $line) { |
| 134 |
if (strpos($line, self::PRIOR_ATTEMPT_EVENT) === false |
| 135 |
&& strpos($line, self::BEACON_BRANCH_EVENT) === false) { |
| 136 |
continue; |
| 137 |
} |
| 138 |
$record = json_decode($line, true); |
| 139 |
if (!is_array($record)) { |
| 140 |
continue; |
| 141 |
} |
| 142 |
$reported = self::reportedOutcome($record); |
| 143 |
if ($reported['id'] === '') { |
| 144 |
continue; |
| 145 |
} |
| 146 |
if (!array_key_exists($reported['id'], $outcomes) || !$reported['ok']) { |
| 147 |
$outcomes[$reported['id']] = $reported['ok']; |
| 148 |
} |
| 149 |
} |
| 150 |
return $outcomes; |
| 151 |
} |
| 152 |
|
| 153 |
/** |
| 154 |
* Every request id condemned anywhere in a stream of journal records. |
| 155 |
* |
| 156 |
* The plugin keeps two independent journals for one request, but the |
| 157 |
* browser's verdicts land in only ONE of them: both events above are |
| 158 |
* written by ABJ_404_Solution_ClientTransportReport through the checkpoint |
| 159 |
* logger. Ranking each journal purely from its own contents therefore left |
| 160 |
* the stage trace unable to see the one failure mode only the browser can |
| 161 |
* report, and an attempt in that state is indistinguishable from healthy |
| 162 |
* traffic in the trace file -- so it was spent on budget like any other |
| 163 |
* completed request. Building this index once and handing it to BOTH |
| 164 |
* selections is what makes the two journals agree. |
| 165 |
* |
| 166 |
* @param array<int, string> $lines JSONL lines, any order. |
| 167 |
* @return array<string, bool> Condemned ids, keyed by id. |
| 168 |
*/ |
| 169 |
public static function requestIdsIn(array $lines): array { |
| 170 |
$ids = array(); |
| 171 |
foreach ($lines as $line) { |
| 172 |
// Only two events can carry a verdict, and this pass runs over |
| 173 |
// whole journals in addition to the ranking pass that follows it, |
| 174 |
// so lines that cannot possibly match are rejected before the JSON |
| 175 |
// decoder sees them. Keyed off the same constants the decoder |
| 176 |
// matches on, so a renamed event cannot make this filter silently |
| 177 |
// start dropping verdicts. |
| 178 |
if (strpos($line, self::PRIOR_ATTEMPT_EVENT) === false |
| 179 |
&& strpos($line, self::BEACON_BRANCH_EVENT) === false) { |
| 180 |
continue; |
| 181 |
} |
| 182 |
$record = json_decode($line, true); |
| 183 |
if (!is_array($record)) { |
| 184 |
continue; |
| 185 |
} |
| 186 |
$condemned = self::condemnedRequestId($record); |
| 187 |
if ($condemned !== '') { |
| 188 |
$ids[$condemned] = true; |
| 189 |
} |
| 190 |
} |
| 191 |
return $ids; |
| 192 |
} |
| 193 |
|
| 194 |
/** |
| 195 |
* The journal key the browser's report is talking about, or '' when it |
| 196 |
* named nothing usable. |
| 197 |
* |
| 198 |
* Every group in a journal is keyed by whatever the browser sent as the |
| 199 |
* wire `requestId`, normalized by the ledger on arrival. The browser sends |
| 200 |
* its PER-ATTEMPT composite id there whenever the attempt recorder is |
| 201 |
* running (`record.id`, e.g. `abc123t2`), and falls back to the LOGICAL |
| 202 |
* request id (`record.rid`, `abc123` -- the prefix every retry of one part |
| 203 |
* shares) only when the recorder did not load and no attempt id exists. So |
| 204 |
* the key is resolved in exactly that order, through exactly that |
| 205 |
* normalization. |
| 206 |
* |
| 207 |
* Reading the logical id alone was a join that could never land: while the |
| 208 |
* recorder runs, no group is ever keyed by it, so the verdict minted an |
| 209 |
* empty placeholder and the attempt that actually failed stayed ranked as |
| 210 |
* healthy context. That silently defeated the one case only the browser |
| 211 |
* can report -- PHP completed the request and the response never arrived. |
| 212 |
* |
| 213 |
* @param array<array-key, mixed> $report |
| 214 |
*/ |
| 215 |
private static function journalKeyOfReportedAttempt(array $report): string { |
| 216 |
foreach (array('id', 'rid') as $field) { |
| 217 |
$key = self::journalKeyOf($report[$field] ?? null); |
| 218 |
if ($key !== '') { |
| 219 |
return $key; |
| 220 |
} |
| 221 |
} |
| 222 |
return ''; |
| 223 |
} |
| 224 |
|
| 225 |
/** |
| 226 |
* One client-sent id as the group key it belongs to, or '' when it named |
| 227 |
* nothing at all. |
| 228 |
* |
| 229 |
* Normalized, not compared raw: an id the ledger refuses was journaled |
| 230 |
* under its unknown-id sentinel, so that sentinel is the group the verdict |
| 231 |
* belongs to. A raw comparison against an already-normalized key can only |
| 232 |
* ever miss. An ABSENT value is different from a refused one and stays '', |
| 233 |
* so a client that named no attempt condemns nothing rather than condemning |
| 234 |
* the unjoinable bucket. |
| 235 |
* |
| 236 |
* @param mixed $raw |
| 237 |
*/ |
| 238 |
private static function journalKeyOf($raw): string { |
| 239 |
if (!is_scalar($raw) || (string)$raw === '') { |
| 240 |
return ''; |
| 241 |
} |
| 242 |
return ABJ_404_Solution_AjaxRequestLedger::normalizeId($raw); |
| 243 |
} |
| 244 |
} |
| 245 |
|