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

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

340 lines 15.1 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 * A bounded, readable excerpt of a set of JSONL diagnostic files.
9 *
10 * The plugin keeps more than one durable diagnostic journal for a single AJAX
11 * request, on purpose: the stage trace (ABJ_404_Solution_AjaxTraceJournal) and
12 * the independent checkpoint log (ABJ_404_Solution_AjaxCheckpointLogger), which
13 * exists precisely so a defect in the trace cannot erase the evidence about it.
14 * Both have to reach the support payload, and both need the same read: whole
15 * lines only, oldest file first, everything inside one byte budget.
16 *
17 * That read lives here rather than in either journal, so the checkpoint logger
18 * never has to depend on the trace journal it is deliberately independent of.
19 *
20 * This class reads bytes and formats the block. It decides nothing about WHICH
21 * files matter (callers supply the paths, the budget and the header) and
22 * nothing about which RECORDS matter -- that ranking is
23 * ABJ_404_Solution_DiagnosticEvidencePriority's, and it is the reason this
24 * class no longer splits the budget evenly per file and no longer takes a
25 * blind byte tail. Measured on a real session, that older read shipped 4 of 72
26 * request IDs and none of the failing ones: the files are one ordered stream of
27 * records now, ranked by what actually failed.
28 */
29 final class ABJ_404_Solution_DiagnosticJournalExcerpt {
30
31 /**
32 * Ceiling on the bytes read from disk before ranking, across all files.
33 *
34 * The allowance is spent NEWEST first, so whatever it cannot cover is the
35 * OLDEST bytes -- which in a failing session are the first failures, the
36 * most diagnostic records there are. That makes an undersized read bound a
37 * SECOND, independent place a session can be lost, on top of rotation, and
38 * the two compound in the worst possible way: immediately after a
39 * rotation, the head of the rotated file is exactly the oldest evidence
40 * and the current file's bytes are spent before it.
41 *
42 * So this is sized to hold the checkpoint channel's whole retained source:
43 * two 4 MB ordinary generations, two 4 MB fixed-intent generations, and
44 * the 1 MB append-only active-operation journal. Whatever survived rotation is then
45 * always fully read, and retention has one owner (the writer bounds)
46 * instead of a smaller second retention window in the reader. Written as
47 * a literal rather than derived from those constants so this class stays
48 * usable by any journal, not only the checkpoint one.
49 *
50 * The ceiling still exists for its original reason: a file that grew past
51 * its own bound (a rotation that could not rename) must not turn a support
52 * click into an out-of-memory admin request. Decoded records are dropped
53 * per line by the ranking pass, so the live cost is the raw lines, not a
54 * parsed copy of them.
55 */
56 const MAX_TOTAL_READ_BYTES = 17825792;
57
58 /** Bytes held back from the content budget for the accounting line and its newline. */
59 const SUMMARY_RESERVE_BYTES = 512;
60
61 /**
62 * Compose one labeled excerpt block from the newest of the given paths.
63 *
64 * Paths that do not exist are skipped: a journal that was never written is
65 * a normal state, not a failure. That silence is only READABLE because the
66 * support collector states, separately and unconditionally, which
67 * directories and files it checked -- see
68 * ABJ_404_Solution_DiagnosticCollectionManifest. Without that manifest an
69 * empty return here is indistinguishable from a wrong directory, a wrong
70 * node, or a regression in this reader, which is precisely how beta.1's
71 * "the journal came back empty" ended up undiagnosable. Do NOT make this
72 * method the place that explains itself: a reader is the last component
73 * that can be trusted to describe its own failure, which is why the
74 * manifest stats the files independently instead.
75 *
76 * @param array<int, string> $paths Candidate files, any order; missing ones are ignored.
77 * @param int $budgetBytes Hard ceiling for the returned string, header included.
78 * @param string $header Section label, e.g. "Recent AJAX stage traces (JSONL):\n".
79 * @param array<string, bool> $knownFailingIds Requests condemned in ANOTHER journal,
80 * from failureIndex(). Without them this excerpt ranks only on what its own
81 * files say, and the browser says it in one journal and not the other.
82 * @param callable(array<int, string>):array<int, string>|null $lineTransform
83 * Channel-specific normalization applied before evidence ranking.
84 * @param array{paths: array<int, string>, manifest: array<string, mixed>}|null $fileSelection
85 * A selection produced by DiagnosticJournalFileSelector. Passing it
86 * keeps the excerpt and collection manifest on one decision.
87 * @return string Empty string when nothing readable was found.
88 */
89 public static function compose(array $paths, int $budgetBytes, string $header,
90 array $knownFailingIds = array(), ?callable $lineTransform = null,
91 ?array $fileSelection = null): string {
92 try {
93 $selection = $fileSelection
94 ?? ABJ_404_Solution_DiagnosticJournalFileSelector::select($paths, $knownFailingIds);
95 $files = isset($selection['paths']) && is_array($selection['paths'])
96 ? $selection['paths'] : array();
97 if ($files === array()) {
98 return '';
99 }
100 $contentBudget = $budgetBytes - strlen($header) - self::SUMMARY_RESERVE_BYTES;
101 if ($contentBudget <= 0) {
102 return '';
103 }
104 $read = self::readLines($files);
105 if ($read['lines'] === array()) {
106 return '';
107 }
108 $lines = $lineTransform === null ? $read['lines'] : $lineTransform($read['lines']);
109 if (!is_array($lines) || $lines === array()) {
110 return '';
111 }
112 $selected = ABJ_404_Solution_DiagnosticEvidencePriority::select(
113 $lines, $contentBudget, $knownFailingIds);
114 if ($selected['lines'] === array()) {
115 return '';
116 }
117 $summary = array_merge($selected['summary'], array(
118 'files_read' => $read['filesRead'],
119 'files_skipped' => $read['filesSkipped'],
120 'bytes_unread' => $read['bytesUnread'],
121 'files_dropped_by_cap' => self::selectionCount($selection, 'dropped_files'),
122 'known_failure_files' => self::selectionCount($selection, 'known_failure_files'),
123 'server_failure_files' => self::selectionCount($selection, 'server_failure_files'),
124 'classification_issue_files' =>
125 self::selectionCount($selection, 'classification_issue_files'),
126 'pinned_files' => self::selectionCount($selection, 'pinned_files'),
127 ));
128 return $header . self::summaryLine($summary) . "\n" . implode("\n", $selected['lines']);
129 } catch (Throwable $e) {
130 self::reportFailure('Diagnostic journal excerpt failed: ' . $e->getMessage());
131 return '';
132 }
133 }
134
135 /**
136 * Every request id the browser condemned in one channel's journals.
137 *
138 * Read as its own pass, BEFORE any excerpt is composed, because the answer
139 * has to be available to a journal that does not contain it: the browser's
140 * verdicts live only in the checkpoint journal, and the stage trace has to
141 * rank the same requests as failing or it spends a browser-lost request's
142 * stage timings on budget as ordinary context. Callers build the index per
143 * channel and pass the union into every compose() call -- one index, both
144 * journals, so the two can never disagree about which requests failed.
145 *
146 * This narrow pass scans every candidate before file selection and retains
147 * only verdict lines. Applying the excerpt's file cap first would erase the
148 * IDs needed to pin the older evidence file.
149 *
150 * @param array<int, string> $paths One channel's candidate files, as compose() takes them.
151 * @return array<string, bool> Condemned request ids, keyed by id; empty when unreadable.
152 */
153 public static function failureIndex(array $paths): array {
154 try {
155 return ABJ_404_Solution_DiagnosticClientVerdict::requestIdsIn(
156 self::failureVerdictLines($paths));
157 } catch (Throwable $e) {
158 self::reportFailure('Diagnostic failure index failed: ' . $e->getMessage());
159 return array();
160 }
161 }
162
163 /**
164 * One channel's journals as a single oldest-first stream of whole lines.
165 *
166 * Whole-journal consumers share the same ordinary-file selection and byte
167 * allowance as an excerpt with no externally known failures.
168 *
169 * @param array<int, string> $paths One channel's candidate files, as compose() takes them.
170 * @return array<int, string> Empty when nothing readable was found.
171 */
172 public static function readAllLines(array $paths): array {
173 try {
174 $selection = ABJ_404_Solution_DiagnosticJournalFileSelector::select($paths);
175 $files = $selection['paths'];
176 if ($files === array()) {
177 return array();
178 }
179 return self::readLines($files)['lines'];
180 } catch (Throwable $e) {
181 self::reportFailure('Diagnostic journal read failed: ' . $e->getMessage());
182 return array();
183 }
184 }
185
186 /**
187 * Browser-verdict lines from every existing candidate, before file capping.
188 *
189 * This pass deliberately has no file-count or byte-budget decision: it
190 * stores only the two event shapes that can condemn another request, so a
191 * later file selection cannot erase the IDs needed to pin their evidence.
192 *
193 * @param array<int, string> $paths
194 * @return array<int, string>
195 */
196 private static function failureVerdictLines(array $paths): array {
197 $lines = array();
198 foreach ($paths as $path) {
199 if (!@is_file($path)) {
200 continue;
201 }
202 $handle = @fopen($path, 'rb');
203 if ($handle === false) {
204 self::reportFailure('Diagnostic failure index could not scan: ' . $path);
205 continue;
206 }
207 try {
208 while (($line = @fgets($handle)) !== false) {
209 if (strpos($line, ABJ_404_Solution_DiagnosticClientVerdict::PRIOR_ATTEMPT_EVENT) === false
210 && strpos($line,
211 ABJ_404_Solution_DiagnosticClientVerdict::BEACON_BRANCH_EVENT) === false) {
212 continue;
213 }
214 $line = trim($line);
215 if ($line !== '') {
216 $lines[] = $line;
217 }
218 }
219 } finally {
220 @fclose($handle);
221 }
222 }
223 return $lines;
224 }
225
226 /**
227 * @param array{manifest?: array<string, mixed>} $selection
228 */
229 private static function selectionCount(array $selection, string $field): int {
230 $manifest = isset($selection['manifest']) && is_array($selection['manifest'])
231 ? $selection['manifest'] : array();
232 return is_numeric($manifest[$field] ?? null) ? (int)$manifest[$field] : 0;
233 }
234
235 /**
236 * Every whole line from the given files as one oldest-first stream.
237 *
238 * The read allowance is consumed newest file first, so if it runs out the
239 * bytes lost are the OLDEST -- but ranking then happens over everything
240 * that was read, which is what stops a busy file from displacing the
241 * failing requests in another one.
242 *
243 * @param array<int, string> $files Oldest first.
244 * @return array{lines: array<int, string>, filesRead: int, filesSkipped: int, bytesUnread: int}
245 */
246 private static function readLines(array $files): array {
247 $allowance = self::MAX_TOTAL_READ_BYTES;
248 $perFile = array();
249 $filesRead = 0;
250 $filesSkipped = 0;
251 $bytesUnread = 0;
252 foreach (array_reverse($files) as $path) {
253 $size = @filesize($path);
254 if (!is_int($size)) {
255 self::reportFailure('Diagnostic journal size could not be read: ' . $path);
256 $filesSkipped++;
257 continue;
258 }
259 if ($allowance <= 0) {
260 $filesSkipped++;
261 $bytesUnread += $size;
262 continue;
263 }
264 $contents = self::readFileTail($path, $allowance);
265 if ($contents === '') {
266 $filesSkipped++;
267 $bytesUnread += $size;
268 continue;
269 }
270 $filesRead++;
271 $bytesUnread += max(0, $size - strlen($contents));
272 $allowance -= strlen($contents);
273 $perFile[] = $contents;
274 }
275
276 $lines = array();
277 foreach (array_reverse($perFile) as $contents) {
278 foreach (explode("\n", $contents) as $line) {
279 $line = trim($line);
280 if ($line !== '') {
281 $lines[] = $line;
282 }
283 }
284 }
285 return array(
286 'lines' => $lines,
287 'filesRead' => $filesRead,
288 'filesSkipped' => $filesSkipped,
289 'bytesUnread' => $bytesUnread,
290 );
291 }
292
293 /**
294 * Last $limit bytes of a file, trimmed forward to a line boundary so a
295 * reader never gets half a JSON record.
296 */
297 private static function readFileTail(string $path, int $limit): string {
298 if ($limit <= 0 || !@is_file($path)) {
299 return '';
300 }
301 $size = @filesize($path);
302 if (!is_int($size)) {
303 self::reportFailure('Diagnostic journal size could not be read: ' . $path);
304 return '';
305 }
306 $offset = max(0, $size - $limit);
307 $contents = @file_get_contents($path, false, null, $offset, $limit);
308 if (!is_string($contents)) {
309 self::reportFailure('Diagnostic journal could not be read: ' . $path);
310 return '';
311 }
312 if ($offset > 0) {
313 $newline = strpos($contents, "\n");
314 $contents = $newline === false ? '' : substr($contents, $newline + 1);
315 }
316 return trim($contents);
317 }
318
319 /**
320 * The accounting line. JSON, so the whole block below the human header
321 * stays parseable as JSONL, and bounded, so it can never eat into the
322 * evidence it is describing.
323 *
324 * @param array<string, int> $summary
325 */
326 private static function summaryLine(array $summary): string {
327 $line = json_encode(array('abj404_excerpt_summary' => $summary), JSON_UNESCAPED_SLASHES);
328 // +1 for the newline that follows it, which the reserve also covers.
329 if (!is_string($line) || strlen($line) + 1 > self::SUMMARY_RESERVE_BYTES) {
330 return '{"abj404_excerpt_summary":{"encoding_failed":1}}';
331 }
332 return $line;
333 }
334
335 private static function reportFailure(string $message): void {
336 // Unconditional; see AjaxCheckpointLogger::reportFailure().
337 abj404_logPhpFallback('ajax-trace', $message);
338 }
339 }
340