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 / diagnostics / DiagnosticClientVerdict.php

DiagnosticClientVerdict.php in 404 Solution trunk, at includes/diagnostics/DiagnosticClientVerdict.php

245 lines 11.0 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 * 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