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

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

180 lines 8.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 * How many bytes the server actually encoded for a browser session's most
9 * recent completed admin-table response.
10 *
11 * The one question that needs BOTH durable journals and can be answered by
12 * neither alone. The stage trace owns the session-to-request join; the
13 * checkpoint journal owns the exact post-json_encode byte count. Joining them
14 * is a distinct responsibility from reading either channel for the support
15 * payload, which is why it lives here rather than inside
16 * ABJ_404_Solution_CheckpointJournalReader: that class answers "what does this
17 * journal say", and this one answers a question about the site.
18 *
19 * Consumed once, by the size_target canary step, so the ladder's size probes
20 * are calibrated against the real failing response rather than a default.
21 *
22 * Every absence is NAMED rather than collapsed into one word. Support report
23 * 2026-08-27 (Azure App Service, plugin 4.3.4) shipped
24 * `{bytes: null, source: "unavailable"}` for a session the browser definitely
25 * had, and nothing in the payload could separate "the client sent no session"
26 * from "the trace journal names no table request for it" (what actually
27 * happened -- the trace writer was never armed) from "the table request ran and
28 * journaled no size". A fabricated size is never returned in any of the three.
29 */
30 final class ABJ_404_Solution_EncodedTableResponseSize {
31
32 /**
33 * The size for one browser session, or a named absence.
34 *
35 * Scans the bounded/rotated journals and the live pending spools of both
36 * channels. Missing, malformed, foreign-session, non-table and zero-byte
37 * records all resolve to a named absence rather than to a number.
38 *
39 * An absence here is not the end of the ladder's size axis: the step falls
40 * through to a live measurement
41 * (ABJ_404_Solution_MeasuredTableResponseSize) and carries this channel's
42 * own answer alongside it as `realResponseRecordedSource`, so the reason
43 * the recorded number was unavailable survives even when a measured one
44 * replaces it. Naming the absence precisely is what makes that pair
45 * readable; see ABJ_404_Solution_AjaxCanaryStepRunner::resolveSizeTarget().
46 *
47 * @return array{bytes: int|null, source: string, request_id: string}
48 */
49 public static function forSession(string $sessionId): array {
50 // Guarded before the reads, not inside fromLines(), so the no-session
51 // case still costs no journal I/O the way it always has.
52 if (substr($sessionId, 0, 64) === '') {
53 return self::noEncodedSize('unavailable');
54 }
55 try {
56 $traceSource = ABJ_404_Solution_AjaxTraceJournal::supportCollectionSource();
57 $source = ABJ_404_Solution_CheckpointJournalReader::supportCollectionSource();
58 return self::fromLines(
59 ABJ_404_Solution_DiagnosticJournalExcerpt::readAllLines($traceSource['paths']),
60 ABJ_404_Solution_DiagnosticJournalExcerpt::readAllLines($source['paths']),
61 $sessionId
62 );
63 } catch (Throwable $e) {
64 // Unconditional; abj404_logPhpFallback() is defined at plugin
65 // entry, before any class here can be autoloaded.
66 abj404_logPhpFallback(
67 'ajax-checkpoint', 'Encoded table response-size lookup failed: ' . $e->getMessage());
68 return self::noEncodedSize('journal_error');
69 }
70 }
71
72 /**
73 * The same answer built from journal lines a caller already holds.
74 *
75 * Exists so a caller that reads the checkpoint journal for its own reasons
76 * can join against the EXACT lines it read. Two independent reads of the
77 * same growing file can straddle a concurrent append, which pairs a record
78 * from one instant with a record from another and reports a comparison
79 * neither read actually saw. ABJ_404_Solution_ResponseBodyDeliveryEvidence
80 * is that caller: it reads the checkpoint journal to find delivery records
81 * and needs the emitted size drawn from the same snapshot.
82 *
83 * The trace and checkpoint journals stay separate parameters because they
84 * are separate files answering separate halves of the join; only the
85 * checkpoint half is shared with that caller.
86 *
87 * @param array<int, string> $traceLines
88 * @param array<int, string> $checkpointLines
89 * @return array{bytes: int|null, source: string, request_id: string}
90 */
91 public static function fromLines(array $traceLines, array $checkpointLines, string $sessionId): array {
92 $sessionId = substr($sessionId, 0, 64);
93 if ($sessionId === '') {
94 return self::noEncodedSize('unavailable');
95 }
96 $tableRequestIds = self::tableRequestIdsInSession($traceLines, $sessionId);
97 if ($tableRequestIds === array()) {
98 return self::noEncodedSize('session_not_traced');
99 }
100 return self::latestEncodedSizeForRequests(
101 $checkpointLines, $tableRequestIds, self::noEncodedSize('no_encoded_size_recorded'));
102 }
103
104 /**
105 * The no-size answer, carrying the reason it is the answer.
106 *
107 * @return array{bytes: int|null, source: string, request_id: string}
108 */
109 private static function noEncodedSize(string $reason): array {
110 return array('bytes' => null, 'source' => $reason, 'request_id' => '');
111 }
112
113 /**
114 * @param array<int, string> $traceLines
115 * @return array<string, bool>
116 */
117 private static function tableRequestIdsInSession(array $traceLines, string $sessionId): array {
118 $requestIds = array();
119 foreach ($traceLines as $line) {
120 if (strpos($line, 'ajaxUpdatePaginationLinks') === false
121 || strpos($line, $sessionId) === false) {
122 continue;
123 }
124 $record = json_decode($line, true);
125 if (!is_array($record)) {
126 continue;
127 }
128 $action = is_scalar($record['action'] ?? null) ? (string)$record['action'] : '';
129 $part = is_scalar($record['part'] ?? null) ? (string)$record['part'] : '';
130 $recordSessionId = is_scalar($record['session_id'] ?? null) ? (string)$record['session_id'] : '';
131 if ($action !== 'ajaxUpdatePaginationLinks' || $part !== 'table'
132 || $recordSessionId !== $sessionId) {
133 continue;
134 }
135 $requestId = is_scalar($record['request_id'] ?? null)
136 ? (string)$record['request_id'] : '';
137 if (preg_match('/^[A-Za-z0-9]{8,64}$/', $requestId) === 1) {
138 $requestIds[$requestId] = true;
139 }
140 }
141 return $requestIds;
142 }
143
144 /**
145 * @param array<int, string> $checkpointLines
146 * @param array<string, bool> $requestIds
147 * @param array{bytes: int|null, source: string, request_id: string} $unavailable
148 * @return array{bytes: int|null, source: string, request_id: string}
149 */
150 private static function latestEncodedSizeForRequests(
151 array $checkpointLines,
152 array $requestIds,
153 array $unavailable
154 ): array {
155 $latest = $unavailable;
156 foreach ($checkpointLines as $line) {
157 if (strpos($line, '"event":"json_encode"') === false) {
158 continue;
159 }
160 $record = json_decode($line, true);
161 if (!is_array($record)) {
162 continue;
163 }
164 $event = is_scalar($record['event'] ?? null) ? (string)$record['event'] : '';
165 $requestId = is_scalar($record['request_id'] ?? null)
166 ? (string)$record['request_id'] : '';
167 $bytes = is_numeric($record['bytes'] ?? null) ? (int)$record['bytes'] : 0;
168 if ($event !== 'json_encode' || !isset($requestIds[$requestId]) || $bytes <= 0) {
169 continue;
170 }
171 $latest = array(
172 'bytes' => $bytes,
173 'source' => 'session_json_encode',
174 'request_id' => $requestId,
175 );
176 }
177 return $latest;
178 }
179 }
180