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

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

353 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 * What the support-request collector LOOKED FOR, stated whether or not it
9 * found anything.
10 *
11 * beta.1's support payload came back with an empty stage-trace section, and
12 * nothing in the report could separate the three very different things that
13 * produce that outcome: the site genuinely wrote no journal, the collector
14 * resolved a different directory (or ran on a different node) than the
15 * requests it is supposed to describe, or the read side itself regressed.
16 * ABJ_404_Solution_DiagnosticJournalExcerpt cannot close that gap on its own:
17 * an excerpt that finds nothing has nothing to hang its accounting line on,
18 * and a reader that is broken is the last thing that should be trusted to
19 * describe its own breakage.
20 *
21 * So this manifest is composed INDEPENDENTLY of the read. It stats the
22 * candidate files itself instead of reporting what the excerpt reader
23 * observed, for the same reason the checkpoint journal is independent of the
24 * stage trace it records: a defect in the component under investigation must
25 * not be able to erase the evidence about it. It is always present in the
26 * payload, even when every channel is silent, and the collector places it
27 * FIRST so the report contract's clamp can never be the thing that removes it.
28 *
29 * It answers, always: which process collected (hostname, PID, SAPI, effective
30 * uid), which directory each channel resolved and whether that directory
31 * exists, is readable, is writable, and which filesystem node it sits on;
32 * which candidate files were checked and their existence, size and mtime; how
33 * many bytes and lines each channel actually yielded; and, when the browser
34 * sent its drained attempt buffer, whether the attempt ids it expects to be
35 * described are present in what was read.
36 */
37 final class ABJ_404_Solution_DiagnosticCollectionManifest {
38
39 /** The one JSON key the whole record hangs under, so a reader can grep for it. */
40 const RECORD_KEY = 'abj404_collection_manifest';
41
42 /** Every channel yielded nothing readable. */
43 const OUTCOME_EMPTY = 'no_evidence_collected';
44
45 /** At least one channel yielded bytes. */
46 const OUTCOME_COLLECTED = 'evidence_collected';
47
48 /**
49 * Evidence whose absence must be stated rather than inferred from silence.
50 *
51 * The detach verdict is assembled independently ahead of this manifest,
52 * so this catalog covers the journal-sourced browser receipt whose loss
53 * could otherwise look exactly like a ladder that never ran.
54 */
55 const REQUIRED_EVIDENCE_RECORDS = array(
56 'canary_step_client_receipt',
57 'concurrent_control_client_receipt',
58 );
59
60 /**
61 * Compose the manifest block. Never returns an empty string: a manifest
62 * that could not be built says so, because "no manifest" is the exact
63 * silence this class exists to end.
64 *
65 * @param array<int, array{channel: string, directory: string, usable: bool, paths: array<int, string>, collected: string, file_selection?: array<string, mixed>}> $channels
66 * One entry per journal the collector actually read, carrying the
67 * candidate paths it used and the text it got back.
68 * @param array{status: string, ids: array<int, string>, records: int} $clientAttempts
69 * ABJ_404_Solution_ClientTransportReport::attemptOutcomesInDrainedBuffer().
70 * @param int $budgetBytes Hard ceiling for the returned string.
71 * @return string
72 */
73 public static function compose(array $channels, array $clientAttempts, int $budgetBytes): string {
74 try {
75 $described = array();
76 $collected = '';
77 foreach ($channels as $channel) {
78 $described[] = self::describeChannel($channel);
79 $collected .= isset($channel['collected']) ? (string)$channel['collected'] : '';
80 }
81 $manifest = array(
82 'collector' => self::collector(),
83 'channels' => $described,
84 'client_expected_attempts' => self::reconcileAttempts($clientAttempts, $collected),
85 'required_evidence_records' => self::reconcileRequiredEvidence($collected),
86 'outcome' => self::outcome($described),
87 );
88 return ABJ_404_Solution_DiagnosticCollectionManifestRenderer::render(
89 $manifest, $budgetBytes);
90 } catch (Throwable $e) {
91 self::reportFailure('Diagnostic collection manifest failed: ' . $e->getMessage());
92 return "Diagnostic collection manifest could not be built:\n"
93 . ABJ_404_Solution_DiagnosticCollectionManifestRenderer::encodeOrEmpty(
94 array(self::RECORD_KEY => array(
95 'error' => substr($e->getMessage(), 0, 200),
96 'outcome' => self::OUTCOME_EMPTY,
97 )));
98 }
99 }
100
101 /**
102 * The process doing the reading.
103 *
104 * The journals record the same identity per request (see
105 * ABJ_404_Solution_RequestEnvironmentFingerprint), so a collector on a
106 * different host or under a different effective uid than the writer is
107 * readable as a mismatch rather than as an absence.
108 *
109 * @return array<string, mixed>
110 */
111 private static function collector(): array {
112 $hostname = ABJ_404_Solution_PhpRuntimeCapabilityAdapter::hostname();
113 return array(
114 'hostname' => $hostname !== null ? $hostname : '',
115 'pid' => ABJ_404_Solution_PhpRuntimeCapabilityAdapter::processId(),
116 'process_token' => ABJ_404_Solution_PhpRuntimeCapabilityAdapter::processToken(),
117 'sapi' => PHP_SAPI,
118 'euid' => ABJ_404_Solution_PhpRuntimeCapabilityAdapter::effectiveUserId(),
119 );
120 }
121
122 /**
123 * One channel's read attempt, described from the filesystem rather than
124 * from whatever the reader reported about itself.
125 *
126 * A channel that knows whether its own WRITER was armed says so
127 * (`writer_arming`). The stats below can prove the collector looked in the
128 * right place; only the writer's policy can separate "there was nothing to
129 * record" from "recording was never switched on", and those are the same
130 * zero files on disk.
131 *
132 * @param array{channel: string, directory: string, usable: bool, paths: array<int, string>, collected: string, file_selection?: array<string, mixed>, writer_arming?: array<string, mixed>} $channel
133 * @return array<string, mixed>
134 */
135 private static function describeChannel(array $channel): array {
136 $directory = isset($channel['directory']) ? (string)$channel['directory'] : '';
137 $collected = isset($channel['collected']) ? (string)$channel['collected'] : '';
138 $paths = isset($channel['paths']) && is_array($channel['paths']) ? $channel['paths'] : array();
139 $fileSelection = isset($channel['file_selection']) && is_array($channel['file_selection'])
140 ? $channel['file_selection'] : array();
141
142 $files = array();
143 $found = 0;
144 foreach ($paths as $path) {
145 $file = self::describeFile((string)$path);
146 if ($file['exists']) {
147 $found++;
148 }
149 $files[] = $file;
150 }
151
152 return array_merge(
153 array(
154 'channel' => isset($channel['channel']) ? (string)$channel['channel'] : 'unknown',
155 'directory' => $directory,
156 'directory_resolved' => $directory !== '',
157 'directory_usable' => !empty($channel['usable']),
158 ),
159 isset($channel['writer_arming']) && is_array($channel['writer_arming'])
160 ? array('writer_arming' => $channel['writer_arming'])
161 : array(),
162 self::describeDirectory($directory),
163 array(
164 'candidates_checked' => count($files),
165 'candidates_found' => $found,
166 'files' => $files,
167 'file_selection' => $fileSelection,
168 'collected_bytes' => strlen($collected),
169 'collected_lines' => self::countLines($collected),
170 )
171 );
172 }
173
174 /**
175 * Existence, permissions and filesystem-node identity of a directory.
176 *
177 * The device and inode are what make "the collector read a different
178 * mount than the writer wrote to" a fact instead of a theory; the count of
179 * this plugin's own files in the directory separates "our files are
180 * elsewhere" from "our files were never written".
181 *
182 * @return array<string, mixed>
183 */
184 private static function describeDirectory(string $directory): array {
185 if ($directory === '') {
186 return array(
187 'directory_exists' => false,
188 'directory_readable' => false,
189 'directory_writable' => false,
190 'directory_real' => null,
191 'directory_device' => null,
192 'directory_inode' => null,
193 'plugin_files_present' => null,
194 );
195 }
196 $exists = @is_dir($directory);
197 $stat = $exists ? @stat($directory) : false;
198 $real = $exists ? @realpath($directory) : false;
199 $matches = $exists ? @glob(rtrim($directory, '/\\') . DIRECTORY_SEPARATOR . 'abj404_*') : false;
200 return array(
201 'directory_exists' => $exists,
202 'directory_readable' => $exists && @is_readable($directory),
203 'directory_writable' => $exists && @is_writable($directory),
204 'directory_real' => is_string($real) ? $real : null,
205 'directory_device' => is_array($stat) && isset($stat['dev']) ? (int)$stat['dev'] : null,
206 'directory_inode' => is_array($stat) && isset($stat['ino']) ? (int)$stat['ino'] : null,
207 'plugin_files_present' => is_array($matches) ? count($matches) : null,
208 );
209 }
210
211 /**
212 * One candidate file. The basename is reported rather than the full path:
213 * the directory is already stated once per channel, and repeating it per
214 * file would spend the manifest's budget on the same string four times.
215 *
216 * @return array{name: string, exists: bool, size: int|null, mtime: int|null, readable: bool}
217 */
218 private static function describeFile(string $path): array {
219 $exists = @is_file($path);
220 $size = $exists ? @filesize($path) : false;
221 $modified = $exists ? @filemtime($path) : false;
222 return array(
223 'name' => basename($path),
224 'exists' => $exists,
225 'size' => is_int($size) ? $size : null,
226 'mtime' => is_int($modified) ? $modified : null,
227 'readable' => $exists && @is_readable($path),
228 );
229 }
230
231 /**
232 * Which of the attempt ids the browser says it is reporting are present in
233 * what the collector actually read.
234 *
235 * A missing id is the single most decisive line in the manifest: the
236 * browser observed the attempt, so the attempt happened, and the collector
237 * did not find it. That is a collection failure, not healthy silence.
238 *
239 * Ids are matched in their quoted JSON form. An attempt id is its logical
240 * request id plus a part/attempt suffix, so an unquoted substring search
241 * would report a logical id as "found" on the strength of a different
242 * attempt's record.
243 *
244 * @param array{status: string, ids: array<int, string>, records: int} $clientAttempts
245 * @return array<string, mixed>
246 */
247 private static function reconcileAttempts(array $clientAttempts, string $collected): array {
248 $ids = isset($clientAttempts['ids']) && is_array($clientAttempts['ids'])
249 ? $clientAttempts['ids'] : array();
250 $found = array();
251 $missing = array();
252 foreach ($ids as $id) {
253 $id = (string)$id;
254 if (strpos($collected, '"' . $id . '"') !== false) {
255 $found[] = $id;
256 } else {
257 $missing[] = $id;
258 }
259 }
260 return array(
261 'status' => isset($clientAttempts['status']) ? (string)$clientAttempts['status'] : 'absent',
262 'buffer_records' => isset($clientAttempts['records']) ? (int)$clientAttempts['records'] : 0,
263 'expected' => count($ids),
264 'found' => count($found),
265 'found_ids' => $found,
266 'missing_ids' => $missing,
267 );
268 }
269
270 /**
271 * @return array<string, array{status: string, reason?: string}>
272 */
273 private static function reconcileRequiredEvidence(string $collected): array {
274 $records = array();
275 $available = array();
276 foreach (explode("\n", $collected) as $line) {
277 if (strpos($line, '"event":"canary_step_client_receipt"') === false
278 && strpos($line, '"event":"concurrent_control_client_receipt"') === false) {
279 continue;
280 }
281 $record = json_decode(trim($line), true);
282 if (!is_array($record)) {
283 continue;
284 }
285 if (self::isAvailableCanaryReceipt($record)) {
286 $available['canary_step_client_receipt'] = true;
287 }
288 if (ABJ_404_Solution_ConcurrentControlReceipt::isCompleteJournalRecord($record)) {
289 $available['concurrent_control_client_receipt'] = true;
290 }
291 }
292 foreach (self::REQUIRED_EVIDENCE_RECORDS as $name) {
293 if (isset($available[$name])) {
294 $records[$name] = array('status' => 'available');
295 continue;
296 }
297 $records[$name] = array(
298 'status' => 'unavailable',
299 'reason' => 'record_not_collected',
300 );
301 }
302 return $records;
303 }
304
305 /** @param array<mixed, mixed> $record */
306 private static function isAvailableCanaryReceipt(array $record): bool {
307 return self::isFullCarriedReceipt($record, 'canary_step_client_receipt')
308 && is_string($record['step_request_id'] ?? null)
309 && $record['step_request_id'] !== '';
310 }
311
312 /**
313 * @param array<mixed, mixed> $record
314 */
315 private static function isFullCarriedReceipt(array $record, string $event): bool {
316 return ($record['envelope'] ?? '') === 'full'
317 && ($record['event'] ?? '') === $event
318 && is_string($record['carried_by'] ?? null)
319 && $record['carried_by'] !== '';
320 }
321
322 /**
323 * @param array<int, array<string, mixed>> $described
324 */
325 private static function outcome(array $described): string {
326 foreach ($described as $channel) {
327 $collectedBytes = $channel['collected_bytes'] ?? 0;
328 if (is_numeric($collectedBytes) && (int)$collectedBytes > 0) {
329 return self::OUTCOME_COLLECTED;
330 }
331 }
332 return self::OUTCOME_EMPTY;
333 }
334
335 private static function countLines(string $text): int {
336 if (trim($text) === '') {
337 return 0;
338 }
339 $lines = 0;
340 foreach (explode("\n", $text) as $line) {
341 if (trim($line) !== '') {
342 $lines++;
343 }
344 }
345 return $lines;
346 }
347
348 private static function reportFailure(string $message): void {
349 // Unconditional; see AjaxCheckpointLogger::reportFailure().
350 abj404_logPhpFallback('support-collection-manifest', $message);
351 }
352 }
353