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 / FailingSessionEvidence.php

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

378 lines 16.3 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 * The detach A/B verdict and encoded-size basis for the session(s) that
9 * actually FAILED, computed from the failing evidence rather than from whoever
10 * happened to click "send debug log to developer" (Bruno timeout gap-hunt
11 * iteration 5, Opus gap 4).
12 *
13 * Browser session identity is deliberately per browser tab: the checkpoint
14 * journal is site-wide while the A/B attempt counters and the encoded-size
15 * lookup are both scoped to one session, so two admin tabs write two
16 * independent sequences into one file. The support-request handler reads only
17 * the CLICKING tab's session id, and both server-side conclusions
18 * (ABJ_404_Solution_DetachAbEvidence::verdictForSession and
19 * ABJ_404_Solution_EncodedTableResponseSize::forSession)
20 * were then scoped to that one session -- even when the failed request ids
21 * carried in the same support payload belong to another tab, or to a tab the
22 * admin has already closed. Multi-tab failure is established for Bruno, so the
23 * raw per-attempt evidence can survive in the journals while the automated
24 * verdict reports "zero matched attempts" for a session that never failed.
25 *
26 * This class closes that gap. It derives the relevant session ids FROM the
27 * failing evidence -- the request ids condemned across the journals plus the
28 * clicking tab's own drained-buffer failures -- maps each one back to the
29 * browser session that owned it, and computes a bounded verdict and
30 * encoded-size basis per failing session. It then states the relationship
31 * between the clicking session and the failing session(s) explicitly, so a
32 * "the click came from a different tab" situation is a stated finding rather
33 * than a silently empty verdict.
34 *
35 * Three properties are load-bearing, not defensive:
36 *
37 * 1. The trace journal is the ONLY channel that carries the raw session id
38 * alongside the request id (the checkpoint journal deliberately stays
39 * minimal and carries only a HASHED session key). So the failing-id ->
40 * session join is read there, and the raw session id it yields is what
41 * both downstream lookups need as their input.
42 * 2. A failing id the clicking tab reported from its OWN drained buffer, but
43 * that the server never traced, belongs to the clicking session by
44 * construction: the browser only holds its own tab's buffer. Those are
45 * attributed to the click session rather than dropped as unresolved.
46 * 3. Unattributable is not failure. A condemned request id with no trace
47 * record at all (never reached PHP, or its trace rotated out) is listed
48 * separately, never folded into a session it cannot be shown to belong
49 * to. Inventing a session would manufacture a per-session verdict out of
50 * an id that has none.
51 */
52 final class ABJ_404_Solution_FailingSessionEvidence {
53
54 /** At least one failing request id was found; per-session diagnostics follow. */
55 const STATUS_COMPUTED = 'computed';
56
57 /** No request id was condemned anywhere, so there is no failing session to diagnose. */
58 const STATUS_NO_FAILING_REQUESTS = 'no_failing_requests';
59
60 /** The join or a per-session computation threw; the reason travels with the record. */
61 const STATUS_ERROR = 'error';
62
63 /** No failing id could be attributed to any browser session. */
64 const REL_NO_FAILING_SESSIONS = 'no_failing_sessions';
65
66 /** Failing sessions exist, but the support request carried no session id to compare them to. */
67 const REL_NO_CLICK_SESSION = 'no_click_session';
68
69 /** Every failing session is the clicking session: the click came from a tab that failed. */
70 const REL_CLICK_SESSION_FAILING = 'click_session_failing';
71
72 /** No failing session is the clicking session: the failures belong to other/closed tabs. */
73 const REL_FOREIGN_SESSIONS_ONLY = 'foreign_sessions_only';
74
75 /** Some failing sessions are the clicking session and some are not. */
76 const REL_MIXED = 'mixed';
77
78 /**
79 * Sessions a full verdict + encoded-size is computed for. Each one costs a
80 * bounded journal read pair, so this caps the work a single support request
81 * pays; sessions past it are still counted toward the click-vs-failing
82 * relationship and reported as omitted, never silently dropped.
83 */
84 const MAX_DIAGNOSED_SESSIONS = 4;
85
86 /** Failing request ids listed inside one session entry. */
87 const MAX_FAILING_IDS_PER_SESSION = 8;
88
89 /** Failing request ids with no resolvable session, listed once on the record. */
90 const MAX_UNRESOLVED_IDS = 12;
91
92 /**
93 * The full per-session failing-evidence record, ready to journal into the
94 * support payload.
95 *
96 * Never throws and never returns a partial shape: every field is present on
97 * every path, and `status` says why the record is what it is. The same
98 * principle DetachAbEvidence follows -- a "nothing to decide" record is
99 * positive evidence, not an absence a reader has to infer.
100 *
101 * @param array<string, bool> $failingIds Every request id condemned across
102 * the journals unioned with the clicking tab's drained-buffer failures,
103 * as ABJ_404_Solution_SupportEvidenceExcerpt already assembled it for the
104 * journal excerpts. Keyed by id.
105 * @param array<string, bool> $clientFailingIds The subset of the above that
106 * came from the clicking tab's OWN drained buffer. Keyed by id. These
107 * belong to the click session even when the server never traced them.
108 * @param string $clickSessionId The browser session the support request was
109 * sent from, bounded to the ledger's 64-character field width here.
110 * @return array<string, mixed>
111 */
112 public static function forSupport(
113 array $failingIds,
114 array $clientFailingIds,
115 string $clickSessionId
116 ): array {
117 $clickSessionId = substr($clickSessionId, 0, 64);
118 $clickSessionKey = ABJ_404_Solution_DetachAbExperiment::sessionKey($clickSessionId);
119 $record = self::emptyRecord($clickSessionKey);
120 try {
121 $failing = self::normalizeIdSet($failingIds);
122 if ($failing === array()) {
123 return $record;
124 }
125 $record['status'] = self::STATUS_COMPUTED;
126 $record['failing_request_count'] = count($failing);
127
128 $traceSource = ABJ_404_Solution_AjaxTraceJournal::supportCollectionSource();
129 $traceLines = ABJ_404_Solution_DiagnosticJournalExcerpt::readAllLines($traceSource['paths']);
130 $sessionByRequestId = self::sessionsForFailingIds($traceLines, $failing);
131
132 $grouped = self::groupBySession(
133 $failing, $sessionByRequestId, $clientFailingIds, $clickSessionId);
134 return self::diagnose($record, $grouped['sessions'], $grouped['unresolved'], $clickSessionKey);
135 } catch (Throwable $e) {
136 $record['status'] = self::STATUS_ERROR;
137 $record['error'] = substr($e->getMessage(), 0, 200);
138 return $record;
139 }
140 }
141
142 /**
143 * The record every path starts from: a complete shape saying nothing
144 * failed, which the "no failing requests" path returns verbatim.
145 *
146 * @return array<string, mixed>
147 */
148 private static function emptyRecord(string $clickSessionKey): array {
149 return array(
150 'status' => self::STATUS_NO_FAILING_REQUESTS,
151 'click_session_key' => $clickSessionKey,
152 'click_vs_failing' => self::REL_NO_FAILING_SESSIONS,
153 'failing_request_count' => 0,
154 'sessions_resolved' => 0,
155 'sessions_omitted' => 0,
156 'sessions' => array(),
157 'unresolved_failing_request_ids' => array(),
158 'unresolved_failing_request_count' => 0,
159 );
160 }
161
162 /**
163 * The raw session id that owns each failing request id, read from the trace
164 * journal -- the only channel that carries request id and raw session id on
165 * the same record.
166 *
167 * Pure and side-effect free: it takes lines rather than reading them, so
168 * every case (a foreign session, an untraced id, a session-less record) is
169 * directly assertable. First writer wins, and the trace stream is oldest
170 * first, so a request's session is fixed by its earliest record and a
171 * later record for the same id cannot move it.
172 *
173 * @param array<int, string> $traceLines JSONL lines, oldest first.
174 * @param array<string, bool> $failing Failing ids to resolve, keyed by id.
175 * @return array<string, string> Request id to raw session id, for the
176 * failing ids that had a trace record naming a non-empty session.
177 */
178 public static function sessionsForFailingIds(array $traceLines, array $failing): array {
179 $sessionByRequestId = array();
180 foreach ($traceLines as $line) {
181 $record = json_decode($line, true);
182 if (!is_array($record)) {
183 continue;
184 }
185 $requestId = self::scalarField($record, 'request_id');
186 if ($requestId === '' || !isset($failing[$requestId])
187 || isset($sessionByRequestId[$requestId])) {
188 continue;
189 }
190 $sessionId = substr(self::scalarField($record, 'session_id'), 0, 64);
191 if ($sessionId !== '') {
192 $sessionByRequestId[$requestId] = $sessionId;
193 }
194 }
195 return $sessionByRequestId;
196 }
197
198 /**
199 * Split the failing ids into per-session buckets and an unresolved list.
200 *
201 * A traced id lands in its own session's bucket. An untraced id that the
202 * clicking tab reported from its own drained buffer is attributed to the
203 * click session (property 2). Everything else is unresolved (property 3).
204 *
205 * @param array<string, bool> $failing
206 * @param array<string, string> $sessionByRequestId
207 * @param array<string, bool> $clientFailingIds
208 * @return array{sessions: array<string, array<int, string>>, unresolved: array<string, bool>}
209 */
210 private static function groupBySession(
211 array $failing,
212 array $sessionByRequestId,
213 array $clientFailingIds,
214 string $clickSessionId
215 ): array {
216 $sessions = array();
217 $unresolved = array();
218 foreach (array_keys($failing) as $id) {
219 $id = (string)$id;
220 if (isset($sessionByRequestId[$id])) {
221 $sessions[$sessionByRequestId[$id]][] = $id;
222 } elseif ($clickSessionId !== '' && isset($clientFailingIds[$id])) {
223 $sessions[$clickSessionId][] = $id;
224 } else {
225 $unresolved[$id] = true;
226 }
227 }
228 return array('sessions' => $sessions, 'unresolved' => $unresolved);
229 }
230
231 /**
232 * Compute a bounded verdict and encoded-size basis for each failing
233 * session, in a deterministic order, and state the click-vs-failing
234 * relationship.
235 *
236 * @param array<string, mixed> $record
237 * @param array<string, array<int, string>> $idsBySession Raw session id to its failing ids.
238 * @param array<string, bool> $unresolved
239 * @return array<string, mixed>
240 */
241 private static function diagnose(
242 array $record,
243 array $idsBySession,
244 array $unresolved,
245 string $clickSessionKey
246 ): array {
247 $keyOf = array();
248 foreach (array_keys($idsBySession) as $rawSessionId) {
249 $keyOf[$rawSessionId] = ABJ_404_Solution_DetachAbExperiment::sessionKey($rawSessionId);
250 }
251 uksort($idsBySession, static function ($a, $b) use ($keyOf) {
252 return strcmp($keyOf[$a], $keyOf[$b]);
253 });
254
255 $sessions = array();
256 $omitted = 0;
257 $sawClick = false;
258 $sawForeign = false;
259 foreach ($idsBySession as $rawSessionId => $ids) {
260 $sessionKey = $keyOf[$rawSessionId];
261 $isClick = $clickSessionKey !== '' && $sessionKey === $clickSessionKey;
262 $isClick ? $sawClick = true : $sawForeign = true;
263 if (count($sessions) >= self::MAX_DIAGNOSED_SESSIONS) {
264 $omitted++;
265 continue;
266 }
267 sort($ids);
268 $sessions[] = array(
269 'session_key' => $sessionKey,
270 'is_click_session' => $isClick,
271 'failing_request_count' => count($ids),
272 'failing_request_ids' => array_slice($ids, 0, self::MAX_FAILING_IDS_PER_SESSION),
273 'detach' => self::compactVerdict(
274 ABJ_404_Solution_DetachAbEvidence::verdictForSession($rawSessionId)),
275 'encoded_size' =>
276 ABJ_404_Solution_EncodedTableResponseSize::forSession(
277 $rawSessionId),
278 );
279 }
280
281 $unresolvedIds = array_keys($unresolved);
282 sort($unresolvedIds);
283 $record['sessions_resolved'] = count($idsBySession);
284 $record['sessions_omitted'] = $omitted;
285 $record['sessions'] = $sessions;
286 $record['unresolved_failing_request_ids'] = array_slice($unresolvedIds, 0, self::MAX_UNRESOLVED_IDS);
287 $record['unresolved_failing_request_count'] = count($unresolvedIds);
288 $record['click_vs_failing'] = self::relationship(
289 $clickSessionKey, $idsBySession !== array(), $sawClick, $sawForeign);
290 return $record;
291 }
292
293 /**
294 * The stated relationship between the clicking session and the failing
295 * session(s) -- the fact the whole class exists to make explicit.
296 *
297 * @param string $clickSessionKey
298 * @param bool $anyFailingSession Whether any failing id resolved to a session.
299 * @param bool $sawClick Whether any failing session is the clicking session.
300 * @param bool $sawForeign Whether any failing session is a different session.
301 */
302 private static function relationship(
303 string $clickSessionKey,
304 bool $anyFailingSession,
305 bool $sawClick,
306 bool $sawForeign
307 ): string {
308 if (!$anyFailingSession) {
309 return self::REL_NO_FAILING_SESSIONS;
310 }
311 if ($clickSessionKey === '') {
312 return self::REL_NO_CLICK_SESSION;
313 }
314 if ($sawClick && $sawForeign) {
315 return self::REL_MIXED;
316 }
317 return $sawClick ? self::REL_CLICK_SESSION_FAILING : self::REL_FOREIGN_SESSIONS_ONLY;
318 }
319
320 /**
321 * The decision plus its accounting from a full DetachAbEvidence verdict,
322 * without the per-attempt list. The clicking session's attempts already
323 * ride the primary detach A/B verdict block; here the quadrant and the
324 * counts are what identify whose data the verdict was drawn from.
325 *
326 * @param array<string, mixed> $verdict ABJ_404_Solution_DetachAbEvidence::verdictForSession().
327 * @return array<string, mixed>
328 */
329 private static function compactVerdict(array $verdict): array {
330 return array(
331 'status' => self::scalarField($verdict, 'status'),
332 'verdict' => isset($verdict['verdict']) && is_array($verdict['verdict'])
333 ? $verdict['verdict'] : array(),
334 'attempts_with_mode' => self::intField($verdict, 'attempts_with_mode'),
335 'attempts_resolved' => self::intField($verdict, 'attempts_resolved'),
336 'attempts_unresolved' => self::intField($verdict, 'attempts_unresolved'),
337 );
338 }
339
340 /**
341 * Keep only well-formed request ids that are present, so a hostile or
342 * malformed key can never reach a journal scan or a session bucket.
343 *
344 * @param array<string, bool> $ids
345 * @return array<string, bool>
346 */
347 private static function normalizeIdSet(array $ids): array {
348 $result = array();
349 foreach ($ids as $id => $present) {
350 $id = (string)$id;
351 if ($present && preg_match('/^[A-Za-z0-9]{1,64}$/', $id) === 1) {
352 $result[$id] = true;
353 }
354 }
355 return $result;
356 }
357
358 /**
359 * One record field as a string, or '' when it is absent or not scalar.
360 *
361 * @param array<array-key, mixed> $record
362 */
363 private static function scalarField(array $record, string $field): string {
364 $value = $record[$field] ?? null;
365 return is_scalar($value) ? (string)$value : '';
366 }
367
368 /**
369 * One record field as an integer, or 0 when it is absent or not scalar.
370 *
371 * @param array<array-key, mixed> $record
372 */
373 private static function intField(array $record, string $field): int {
374 $value = $record[$field] ?? null;
375 return is_scalar($value) ? (int)$value : 0;
376 }
377 }
378