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

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

447 lines 19.8 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 process, what code, and what runtime state is serving this request.
9 *
10 * When a request is slow or vanishes, the first question is not "which query
11 * was slow" but "is this the expected code on a healthy process". This class
12 * captures prior process lifetime, SAPI/host/PID, disk and opcode-cache file
13 * fingerprints, size-only request/admin-user state, output buffers, session
14 * state, one timed object-cache read, and raw resource counters.
15 *
16 * Every probe degrades independently: a platform without getrusage() loses
17 * that one field, never the whole capture.
18 */
19 final class ABJ_404_Solution_RequestEnvironmentFingerprint {
20
21 /** @var ABJ_404_Solution_Clock */
22 private $clock;
23
24 public function __construct(ABJ_404_Solution_Clock $clock) {
25 $this->clock = $clock;
26 }
27
28 /**
29 * Capture the full environment field set.
30 *
31 * @param string|null $handlerClass Class whose loaded file to fingerprint alongside our own.
32 * @param string $cacheProbeKey Request-unique key, so the timed cache read cannot be served from a prior request's entry.
33 * @return array<string, mixed>
34 */
35 public function capture(?string $handlerClass, string $cacheProbeKey): array {
36 // One opcode-cache read for the whole request: see
37 // ABJ_404_Solution_OpcacheGenerationProbe. The two detailed file
38 // fingerprints below and the whole-path module manifest both
39 // reconcile against it, so the most expensive probe in this class
40 // runs once rather than per consumer.
41 $opcache = ABJ_404_Solution_OpcacheGenerationProbe::read();
42 $loadedFiles = $opcache->annotate($this->loadedFileFingerprints($handlerClass));
43 // The whole diagnostic path, not just this file and the handler:
44 // see ABJ_404_Solution_DiagnosticModuleManifest.
45 $buildManifest = ABJ_404_Solution_DiagnosticModuleManifest::capture($opcache);
46 $cacheProbe = $this->timedCacheProbe($cacheProbeKey);
47 $cronDue = $this->cronDueEvents();
48 $obInventory = function_exists('ob_get_status') ? ob_get_status(true) : array();
49 $rusage = ABJ_404_Solution_PhpRuntimeCapabilityAdapter::resourceUsage();
50 $hostname = ABJ_404_Solution_PhpRuntimeCapabilityAdapter::hostname();
51 $pid = ABJ_404_Solution_PhpRuntimeCapabilityAdapter::processId();
52
53 return array_merge(self::bootDelta($this->clock->nowFloat()), array(
54 'sapi' => PHP_SAPI,
55 'hostname' => $hostname !== null
56 ? $hostname
57 : (is_scalar($_SERVER['SERVER_NAME'] ?? null) ? (string)$_SERVER['SERVER_NAME'] : ''),
58 'pid' => $pid,
59 'pid_status' => $pid !== null ? 'available'
60 : (ABJ_404_Solution_PhpRuntimeCapabilityAdapter::isFunctionAvailable('getmypid')
61 ? 'invalid_result' : 'function_unavailable'),
62 'process_token' => ABJ_404_Solution_PhpRuntimeCapabilityAdapter::processToken(),
63 // The union across every capability boundary, composed here rather
64 // than inside one of them: a host that removed the pcntl or OPcache
65 // extension is exactly the host whose report needs to say so, and
66 // asking only the main adapter would silently drop five names.
67 'disabled_functions' => ABJ_404_Solution_PhpRuntimeCapabilityAdapter::disabledAmong(
68 array_merge(
69 ABJ_404_Solution_PhpRuntimeCapabilityAdapter::ownedFunctions(),
70 ABJ_404_Solution_PcntlSignalAdapter::ownedFunctions(),
71 ABJ_404_Solution_OpcacheAdapter::ownedFunctions()
72 )
73 ),
74 'diagnostic_build_id' => defined('ABJ404_DIAGNOSTIC_BUILD_ID')
75 ? (string)ABJ404_DIAGNOSTIC_BUILD_ID
76 : ABJ_404_Solution_AjaxCheckpointLogger::DIAGNOSTIC_BUILD_ID,
77 'plugin_build_hash' => $this->computeBuildHash($loadedFiles, $buildManifest),
78 'loaded_files' => $loadedFiles,
79 'build_manifest' => $buildManifest,
80 'opcache' => $opcache->summary(),
81 'request_shape' => $this->requestShape(),
82 'admin_user_state' => $this->adminUserState(),
83 'ob_inventory' => $obInventory,
84 'session_status' => function_exists('session_status') ? session_status() : null,
85 'cache_probe_ms' => $cacheProbe['elapsed_ms'],
86 'cache_probe_result' => $cacheProbe['result'],
87 'hrtime_ns' => function_exists('hrtime') ? hrtime(true) : null,
88 'wall_clock' => $this->clock->nowFloat(),
89 'rusage' => is_array($rusage) ? $rusage : null,
90 'host_pressure' => ABJ_404_Solution_HostPressureSampler::capture($cacheProbeKey),
91 'cron_doing_transient' => $this->cronDoingTransient(),
92 'cron_disable_wp_cron' => defined('DISABLE_WP_CRON') && DISABLE_WP_CRON,
93 'cron_alternate_wp_cron' => defined('ALTERNATE_WP_CRON') && ALTERNATE_WP_CRON,
94 'cron_due_event_count' => $cronDue['count'],
95 'cron_due_event_error' => $cronDue['error'],
96 ));
97 }
98
99 /**
100 * Delta in milliseconds from REQUEST_TIME_FLOAT to `$nowFloat`, and the
101 * raw REQUEST_TIME_FLOAT itself. Static and dependency-free (no Clock
102 * instance) so boot-phase checkpoints -- recorded before the service
103 * container exists, let alone this class's constructor dependency --
104 * share the exact same formula as capture()'s own boot_delta_ms instead
105 * of a second copy that could silently drift from it (see
106 * ABJ_404_Solution_BootWaypointRecorder::record()).
107 *
108 * @return array{request_time_float: float|null, boot_delta_ms: int|null}
109 */
110 public static function bootDelta(float $nowFloat): array {
111 $requestTimeFloatRaw = $_SERVER['REQUEST_TIME_FLOAT'] ?? null;
112 $requestTimeFloat = is_numeric($requestTimeFloatRaw) ? (float)$requestTimeFloatRaw : null;
113 return array(
114 'request_time_float' => $requestTimeFloat,
115 'boot_delta_ms' => $requestTimeFloat !== null
116 ? max(0, (int)round(($nowFloat - $requestTimeFloat) * 1000))
117 : null,
118 );
119 }
120
121 /**
122 * The `doing_cron` transient's raw value (a float timestamp) when a cron
123 * run is in progress or was recently spawned, or null otherwise. Bruno
124 * timeout matrix cause D: WP hooks wp_cron() on `init` for admin-ajax
125 * requests too, and a loopback spawn is a known failure class for this
126 * project on a Cloudflare + LiteSpeed stack (see the "Sort-prep tooltip
127 * 0%: wp-cron loopback 403" incident). This makes "this request paid for
128 * a cron spawn" a readable fact instead of an inference.
129 *
130 * @return float|null
131 */
132 private function cronDoingTransient(): ?float {
133 if (!function_exists('get_transient')) {
134 return null;
135 }
136 $value = get_transient('doing_cron');
137 return $value !== false && is_numeric($value) ? (float)$value : null;
138 }
139
140 /**
141 * Count of scheduled cron events whose timestamp is already due, plus why
142 * that count is unavailable when it is.
143 *
144 * Every probe here degrades independently: an exception or a malformed
145 * filtered return from a third-party plugin (see the
146 * `pre_get_ready_cron_jobs` filter) must not break the capture. But
147 * degrading is not the same as forgetting -- a `count` of null on a WP 5.0
148 * site (wp_get_ready_cron_jobs() arrived in 5.1; this plugin supports 5.0)
149 * and a null because a foreign cron filter threw are opposite findings,
150 * and the second one is itself cause-D evidence. `error` is what tells
151 * them apart, so the reason rides in the payload next to the outcome
152 * rather than only in a log the reader may not have.
153 *
154 * @return array{count: int|null, error: string|null}
155 */
156 private function cronDueEvents(): array {
157 if (!function_exists('wp_get_ready_cron_jobs')) {
158 return array('count' => null, 'error' => 'wp_get_ready_cron_jobs-unavailable');
159 }
160 try {
161 $due = wp_get_ready_cron_jobs();
162 } catch (Throwable $e) {
163 $this->reportProbeFailure('cron-due-events', $e);
164 return array(
165 'count' => null,
166 'error' => get_class($e) . ': ' . substr($e->getMessage(), 0, 200),
167 );
168 }
169 if (!is_array($due)) {
170 return array('count' => null, 'error' => 'unexpected-shape:' . gettype($due));
171 }
172 $count = 0;
173 foreach ($due as $cronHooks) {
174 if (!is_array($cronHooks)) {
175 continue;
176 }
177 foreach ($cronHooks as $instances) {
178 $count += is_array($instances) ? count($instances) : 1;
179 }
180 }
181 return array('count' => $count, 'error' => null);
182 }
183
184 /**
185 * Fingerprint this class's own loaded file plus the request's handler
186 * (path, content hash, mtime, inode, size). Lets a later reader tell
187 * "opcode cache serving stale bytecode from a prior deploy" apart from
188 * "code is what we think it is and something downstream is slow".
189 *
190 * @return array<int, array<string, mixed>>
191 */
192 private function loadedFileFingerprints(?string $handlerClass): array {
193 $files = array($this->fileFingerprint('trace', __FILE__));
194 if ($handlerClass !== null && class_exists($handlerClass, false)) {
195 try {
196 $reflection = new ReflectionClass($handlerClass);
197 $handlerFile = $reflection->getFileName();
198 if (is_string($handlerFile) && $handlerFile !== '') {
199 $files[] = $this->fileFingerprint('handler', $handlerFile);
200 }
201 } catch (Throwable $e) {
202 $files[] = array('role' => 'handler', 'path' => $handlerClass, 'error' => substr($e->getMessage(), 0, 200));
203 }
204 }
205 return $files;
206 }
207
208 /** @return array<string, mixed> */
209 private function fileFingerprint(string $role, string $path): array {
210 $isFile = @is_file($path);
211 return array(
212 'role' => $role,
213 'path' => $path,
214 'hash' => $isFile ? @md5_file($path) : null,
215 'mtime' => $isFile ? @filemtime($path) : null,
216 'inode' => $isFile ? @fileinode($path) : null,
217 'size' => $isFile ? @filesize($path) : null,
218 );
219 }
220
221 /**
222 * Approximate the received HTTP header block exactly as `Name: value` CRLF
223 * lines, plus raw cookie bytes and pair count. Values never leave memory.
224 *
225 * @return array{header_bytes: int, header_count: int, cookie_header_bytes: int, cookie_count: int}
226 */
227 private function requestShape(): array {
228 $headerBytes = 0;
229 $headerCount = 0;
230 foreach ($_SERVER as $key => $value) {
231 if (!is_string($key) || !is_scalar($value)) {
232 continue;
233 }
234 if (strpos($key, 'HTTP_') === 0) {
235 $name = str_replace(' ', '-', ucwords(strtolower(str_replace('_', ' ', substr($key, 5)))));
236 } elseif ($key === 'CONTENT_TYPE' || $key === 'CONTENT_LENGTH') {
237 $name = str_replace('_', '-', ucwords(strtolower($key), '_'));
238 } else {
239 continue;
240 }
241 $headerBytes += strlen($name . ': ' . (string)$value . "\r\n");
242 $headerCount++;
243 }
244
245 $cookieHeader = is_scalar($_SERVER['HTTP_COOKIE'] ?? null)
246 ? (string)$_SERVER['HTTP_COOKIE'] : '';
247 $cookieCount = 0;
248 foreach (explode(';', $cookieHeader) as $cookiePair) {
249 if (trim($cookiePair) !== '') {
250 $cookieCount++;
251 }
252 }
253 return array(
254 'header_bytes' => $headerBytes,
255 'header_count' => $headerCount,
256 'cookie_header_bytes' => strlen($cookieHeader),
257 'cookie_count' => $cookieCount,
258 );
259 }
260
261 /**
262 * Fingerprint the current administrator's potentially pathological state.
263 * Only aggregate byte sizes and SHA-256 hashes are returned; meta keys and
264 * values remain local to the request.
265 *
266 * @return array<string, mixed>
267 */
268 private function adminUserState(): array {
269 $state = array(
270 'reason' => 'user-api-unavailable',
271 'wp_user_settings_bytes' => null,
272 'wp_user_settings_hash' => null,
273 'screen_option_meta_count' => null,
274 'screen_option_meta_bytes' => null,
275 'screen_option_meta_hash' => null,
276 'locale' => null,
277 'locale_error' => null,
278 );
279 try {
280 $userId = function_exists('get_current_user_id') ? (int)get_current_user_id() : 0;
281 } catch (Throwable $e) {
282 $this->reportProbeFailure('current-user-id', $e);
283 $state['reason'] = 'user-api-exception:' . get_class($e);
284 return $state;
285 }
286 $resolvedLocale = $this->resolvedUserLocale($userId);
287 $state['locale'] = $resolvedLocale['locale'];
288 $state['locale_error'] = $resolvedLocale['error'];
289 if ($userId < 1) {
290 $state['reason'] = 'no-current-user';
291 return $state;
292 }
293 if (!function_exists('get_user_meta')) {
294 return $state;
295 }
296
297 try {
298 $allMeta = get_user_meta($userId);
299 } catch (Throwable $e) {
300 $this->reportProbeFailure('admin-user-state', $e);
301 $state['reason'] = 'user-meta-exception:' . get_class($e);
302 return $state;
303 }
304 if (!is_array($allMeta)) {
305 $state['reason'] = 'user-meta-invalid-shape';
306 return $state;
307 }
308
309 $wpSettings = $allMeta['wp_user-settings'] ?? array();
310 $wpSettingsBytes = $this->metaValueBytes($wpSettings);
311 if ($wpSettingsBytes > 0) {
312 $state['wp_user_settings_bytes'] = $wpSettingsBytes;
313 $state['wp_user_settings_hash'] = hash('sha256', serialize($wpSettings));
314 }
315
316 $screenOptions = array();
317 foreach ($allMeta as $key => $values) {
318 if (is_string($key) && $this->isScreenOptionMetaKey($key)) {
319 $screenOptions[$key] = $values;
320 }
321 }
322 ksort($screenOptions);
323 $state['screen_option_meta_count'] = count($screenOptions);
324 $state['screen_option_meta_bytes'] = $this->metaValueBytes($screenOptions);
325 $state['screen_option_meta_hash'] = $screenOptions !== array()
326 ? hash('sha256', serialize($screenOptions)) : null;
327 $state['reason'] = 'available';
328 return $state;
329 }
330
331 /**
332 * The locale WordPress would render this request in, plus why it is
333 * unavailable when it is.
334 *
335 * Same two-channel convention as cronDueEvents() and timedCacheProbe(),
336 * for the same reason: a bare null cannot tell "this site has no locale
337 * API at all" apart from "a plugin's locale filter is fatal here", and
338 * the second is a finding rather than a shrug. `locale` keeps its own
339 * narrow domain (a locale code, or null) so no reader has to parse a
340 * failure out of it; the reason travels beside it in `error`, and the
341 * full message and code go to the PHP error log via
342 * reportProbeFailure().
343 *
344 * @return array{locale: string|null, error: string|null}
345 */
346 private function resolvedUserLocale(int $userId): array {
347 try {
348 if ($userId > 0 && function_exists('get_user_locale')) {
349 $locale = get_user_locale($userId);
350 } elseif (function_exists('get_locale')) {
351 $locale = get_locale();
352 } else {
353 return array('locale' => null, 'error' => 'locale-api-unavailable');
354 }
355 } catch (Throwable $e) {
356 $this->reportProbeFailure('user-locale', $e);
357 return array(
358 'locale' => null,
359 'error' => get_class($e) . ': ' . substr($e->getMessage(), 0, 200),
360 );
361 }
362 if (!is_scalar($locale)) {
363 return array('locale' => null, 'error' => 'unexpected-shape:' . gettype($locale));
364 }
365 return array('locale' => substr((string)$locale, 0, 32), 'error' => null);
366 }
367
368 private function isScreenOptionMetaKey(string $key): bool {
369 return preg_match('/(?:_per_page$|^screen_layout_|^metaboxhidden_|^closedpostboxes_|^meta-box-order_|^manage.*columnshidden$)/', $key) === 1;
370 }
371
372 /** @param mixed $value */
373 private function metaValueBytes($value): int {
374 if (is_array($value)) {
375 $bytes = 0;
376 foreach ($value as $item) {
377 $bytes += $this->metaValueBytes($item);
378 }
379 return $bytes;
380 }
381 return is_scalar($value) ? strlen((string)$value) : strlen(serialize($value));
382 }
383
384 private function reportProbeFailure(string $probe, Throwable $error): void {
385 if (function_exists('abj404_logPhpFallback')) {
386 abj404_logPhpFallback('request-environment', $probe . ' probe failed: '
387 . get_class($error) . ' code=' . $error->getCode() . ' message=' . $error->getMessage());
388 }
389 }
390
391 /**
392 * One combined hash summarizing "what code is actually loaded for this
393 * request" (plugin version, the content hash + mtime of every
394 * fingerprinted file, and the whole diagnostic module manifest). A build
395 * hash that differs between two requests hitting the same deployed
396 * version is direct proof of opcache/deploy staleness (cause D in the
397 * timeout matrix).
398 *
399 * The manifest hash is folded in rather than left as a separate scalar so
400 * this ONE field answers the question it claims to answer. Before gap GF
401 * it covered two files, which meant a stale or half-deployed request
402 * driver, journal, response emitter, canary, or support collector left
403 * plugin_build_hash completely unchanged -- a build fingerprint that
404 * agreed with itself while the code under investigation had drifted.
405 *
406 * @param array<int, array<string, mixed>> $files
407 * @param array<string, mixed> $buildManifest
408 */
409 private function computeBuildHash(array $files, array $buildManifest): string {
410 $parts = array(defined('ABJ404_VERSION') ? (string)ABJ404_VERSION : 'unknown');
411 foreach ($files as $file) {
412 $hash = $file['hash'] ?? '';
413 $mtime = $file['mtime'] ?? '';
414 $parts[] = (is_scalar($hash) ? (string)$hash : '') . ':' . (is_scalar($mtime) ? (string)$mtime : '');
415 }
416 $manifestHash = $buildManifest['hash'] ?? '';
417 $parts[] = 'manifest:' . (is_scalar($manifestHash) ? (string)$manifestHash : '');
418 return sha1(implode('|', $parts));
419 }
420
421 /** @return array{elapsed_ms: int, result: string} */
422 private function timedCacheProbe(string $cacheProbeKey): array {
423 $startedAt = $this->clock->nowFloat();
424 $result = 'unavailable';
425 if (function_exists('wp_cache_get')) {
426 try {
427 $hit = wp_cache_get($cacheProbeKey, 'abj404');
428 $result = $hit === false ? 'miss' : 'hit';
429 } catch (Throwable $e) {
430 // Naming the class keeps this field's small readable domain
431 // ('unavailable' / 'miss' / 'hit') intact while telling a
432 // thrown drop-in apart from every other way the read can
433 // fail -- the same 'user-api-exception:' . get_class($e)
434 // convention adminUserState() uses above. The full message
435 // and code go to the PHP error log, which is the channel
436 // that can carry them without a size or PII budget.
437 $this->reportProbeFailure('object-cache-read', $e);
438 $result = 'error:' . get_class($e);
439 }
440 }
441 return array(
442 'elapsed_ms' => max(0, (int)round(($this->clock->nowFloat() - $startedAt) * 1000)),
443 'result' => $result,
444 );
445 }
446 }
447