| 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 |
|