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

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

347 lines 12.3 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 * Durably brackets third-party object-cache metrics inspection.
9 *
10 * A metrics capability check, metrics() call, or magic counter property can
11 * block before AjaxRowLoopProgress writes the row checkpoint it was building.
12 * Each potentially foreign boundary therefore gets its own start/end pair.
13 * A killed worker leaves an unmatched start that identifies diagnostics work,
14 * its source, and its row-loop phase without exposing cache values, property
15 * names, or exception text.
16 *
17 * The row-operation cache decorator is unwrapped before inspection. This keeps
18 * diagnostics-owned reads out of the rendered-row Redis attribution channel
19 * and supports nested decorators without invoking their magic pass-through.
20 *
21 * allow-no-test-found: exercised through the real AJAX table render entry point in tests/AjaxRowProgressAttributionTest.php
22 */
23 final class ABJ_404_Solution_CacheMetricsProbeTracer {
24
25 /** @var string */
26 private $requestId;
27
28 /** @var int */
29 private $operationSequence = 0;
30
31 /** @var bool */
32 private $failed = false;
33
34 public function __construct(string $requestId) {
35 $this->requestId = $requestId;
36 }
37
38 /**
39 * Read one cumulative cache snapshot without letting optional diagnostics
40 * break the table response.
41 *
42 * @param mixed $cache WordPress's current object-cache global.
43 * @return array{src: string, calls: int|null, reads: int|null, writes: int|null, hits: int|null, misses: int|null, ms: float|null}
44 */
45 public function snapshot($cache, string $phase): array {
46 if (!is_object($cache)) {
47 return self::emptySnapshot('none');
48 }
49 if ($this->failed) {
50 return self::emptySnapshot('error');
51 }
52
53 $cache = self::unwrapCache($cache);
54 $phase = self::safePhase($phase);
55 $capability = $this->probe(
56 'metrics_capability',
57 $phase,
58 static fn() => self::metricsReader($cache)
59 );
60 if (!$capability['ok']) {
61 $this->failed = true;
62 return self::emptySnapshot('error');
63 }
64
65 $metricsReader = $capability['value'];
66 if ($metricsReader !== null) {
67 $metrics = $this->probe('metrics', $phase, static function () use ($metricsReader): array {
68 return self::snapshotFromMetrics(call_user_func($metricsReader));
69 });
70 if (!$metrics['ok']) {
71 $this->failed = true;
72 return self::emptySnapshot('error');
73 }
74 $metricsSnapshot = $metrics['value'];
75 if ($metricsSnapshot !== null && $metricsSnapshot['src'] !== 'none') {
76 return $metricsSnapshot;
77 }
78 }
79
80 $counters = $this->probe('counters', $phase, static function () use ($cache): array {
81 return self::snapshotFromCounters($cache);
82 });
83 if (!$counters['ok']) {
84 $this->failed = true;
85 return self::emptySnapshot('error');
86 }
87 return $counters['value'] ?? self::emptySnapshot('none');
88 }
89
90 /**
91 * @template T
92 * @param callable(): T $work
93 * @return array{ok: bool, value: T|null}
94 */
95 private function probe(string $source, string $phase, callable $work): array {
96 $fields = array(
97 'operation_id' => $this->operationId($source, $phase),
98 'source' => $source,
99 'phase' => $phase,
100 );
101 $checkpointId = $this->writeStart($fields);
102 $startedAt = self::nowFloat();
103 try {
104 $value = $work();
105 } catch (Throwable $e) {
106 $this->writeEnd($checkpointId, array_merge($fields, array(
107 'status' => 'error',
108 'elapsed_ms' => self::elapsedMilliseconds($startedAt),
109 'error' => self::errorSummary($e),
110 )));
111 self::reportFailure($source . ' probe failed: ' . $e->getMessage());
112 return array('ok' => false, 'value' => null);
113 }
114 $this->writeEnd($checkpointId, array_merge($fields, array(
115 'status' => 'complete',
116 'elapsed_ms' => self::elapsedMilliseconds($startedAt),
117 'result' => self::resultSummary($source, $value),
118 )));
119 return array('ok' => true, 'value' => $value);
120 }
121
122 /** @return (callable(): mixed)|null */
123 private static function metricsReader(object $cache): ?callable {
124 $reader = array($cache, 'metrics');
125 return is_callable($reader) ? $reader : null;
126 }
127
128 /** @param array<string, mixed> $fields */
129 private function writeStart(array $fields): string {
130 try {
131 return ABJ_404_Solution_DurableOperationRecorder::recordStart(
132 $this->requestId,
133 'cache_metrics_probe_start',
134 $fields
135 );
136 } catch (Throwable $e) {
137 self::reportFailure('cache metrics probe start failed: ' . $e->getMessage());
138 return '';
139 }
140 }
141
142 /** @param array<string, mixed> $fields */
143 private function writeEnd(string $checkpointId, array $fields): void {
144 try {
145 ABJ_404_Solution_DurableOperationRecorder::recordEnd(
146 $this->requestId,
147 'cache_metrics_probe_end',
148 $checkpointId,
149 $fields
150 );
151 } catch (Throwable $e) {
152 self::reportFailure('cache metrics probe end failed: ' . $e->getMessage());
153 }
154 }
155
156 private function operationId(string $source, string $phase): string {
157 $this->operationSequence++;
158 return substr(hash(
159 'sha256',
160 $this->requestId . '|' . $source . '|' . $phase . '|' . $this->operationSequence
161 ), 0, 12);
162 }
163
164 private static function unwrapCache(object $cache): object {
165 $seen = array();
166 while ($cache instanceof ABJ_404_Solution_InstrumentedObjectCache) {
167 $id = spl_object_id($cache);
168 if (isset($seen[$id])) {
169 break;
170 }
171 $seen[$id] = true;
172 $next = $cache->originalCache();
173 if ($next === $cache) {
174 break;
175 }
176 $cache = $next;
177 }
178 return $cache;
179 }
180
181 /**
182 * @param mixed $value
183 * @return array{src: string, calls: int|null, reads: int|null, writes: int|null, hits: int|null, misses: int|null, ms: float|null}
184 */
185 private static function snapshotFromMetrics($value): array {
186 $metrics = self::numericMetrics($value);
187 $reads = self::metric($metrics, array('store-reads', 'reads'));
188 $writes = self::metric($metrics, array('store-writes', 'writes'));
189 $hits = self::metric($metrics, array('hits', 'cache-hits'));
190 $misses = self::metric($metrics, array('misses', 'cache-misses'));
191 $milliseconds = self::metric($metrics, array('ms-cache', 'cache-ms', 'cache-time'));
192 if ($reads === null && $writes === null && $hits === null && $misses === null
193 && $milliseconds === null) {
194 return self::emptySnapshot('none');
195 }
196 return array(
197 'src' => 'ocp_metrics',
198 'calls' => self::cacheCallTotal($reads, $writes, $hits, $misses),
199 'reads' => ($reads === null) ? null : (int)$reads,
200 'writes' => ($writes === null) ? null : (int)$writes,
201 'hits' => ($hits === null) ? null : (int)$hits,
202 'misses' => ($misses === null) ? null : (int)$misses,
203 'ms' => $milliseconds,
204 );
205 }
206
207 /**
208 * @return array{src: string, calls: int|null, reads: null, writes: null, hits: int|null, misses: int|null, ms: null}
209 */
210 private static function snapshotFromCounters(object $cache): array {
211 $hits = isset($cache->cache_hits) && is_numeric($cache->cache_hits)
212 ? (int)$cache->cache_hits : null;
213 $misses = isset($cache->cache_misses) && is_numeric($cache->cache_misses)
214 ? (int)$cache->cache_misses : null;
215 if ($hits === null && $misses === null) {
216 return self::emptySnapshot('none');
217 }
218 return array(
219 'src' => 'wp_counters',
220 'calls' => (int)(($hits ?? 0) + ($misses ?? 0)),
221 'reads' => null,
222 'writes' => null,
223 'hits' => $hits,
224 'misses' => $misses,
225 'ms' => null,
226 );
227 }
228
229 /** @return array{src: string, calls: null, reads: null, writes: null, hits: null, misses: null, ms: null} */
230 private static function emptySnapshot(string $source): array {
231 return array(
232 'src' => $source,
233 'calls' => null,
234 'reads' => null,
235 'writes' => null,
236 'hits' => null,
237 'misses' => null,
238 'ms' => null,
239 );
240 }
241
242 private static function cacheCallTotal(
243 ?float $reads,
244 ?float $writes,
245 ?float $hits,
246 ?float $misses
247 ): ?int {
248 if ($reads !== null || $writes !== null) {
249 return (int)(($reads ?? 0) + ($writes ?? 0));
250 }
251 return ($hits !== null || $misses !== null)
252 ? (int)(($hits ?? 0) + ($misses ?? 0))
253 : null;
254 }
255
256 /**
257 * @param mixed $value
258 * @return array<string, float>
259 */
260 private static function numericMetrics($value, int $depth = 0): array {
261 if ($depth > 2 || (!is_array($value) && !is_object($value))) {
262 return array();
263 }
264 $out = array();
265 foreach ((array)$value as $key => $metricValue) {
266 $name = strtolower((string)preg_replace('/^.*\x00/', '', (string)$key));
267 $name = str_replace('_', '-', $name);
268 if (is_numeric($metricValue)) {
269 $out[$name] = (float)$metricValue;
270 } elseif (is_array($metricValue) || is_object($metricValue)) {
271 $out = array_replace($out, self::numericMetrics($metricValue, $depth + 1));
272 }
273 }
274 return $out;
275 }
276
277 /**
278 * @param array<string, float> $metrics
279 * @param array<int, string> $names
280 */
281 private static function metric(array $metrics, array $names): ?float {
282 foreach ($names as $name) {
283 if (array_key_exists($name, $metrics)) {
284 return $metrics[$name];
285 }
286 }
287 return null;
288 }
289
290 /** @param mixed $value */
291 private static function resultSummary(string $source, $value): string {
292 if ($source === 'metrics_capability') {
293 return $value !== null ? 'available' : 'unavailable';
294 }
295 return is_array($value) && ($value['src'] ?? 'none') !== 'none'
296 ? 'snapshot_available'
297 : 'snapshot_unavailable';
298 }
299
300 /** @return array{class: string, code: int, message: string, message_length: int} */
301 private static function errorSummary(Throwable $error): array {
302 $message = $error->getMessage();
303 return array(
304 'class' => self::safeClassName(get_class($error)),
305 'code' => is_int($error->getCode()) ? $error->getCode() : 0,
306 'message' => 'message#' . substr(hash('sha256', $message), 0, 12),
307 'message_length' => strlen($message),
308 );
309 }
310
311 private static function safeClassName(string $class): string {
312 return preg_match('/^[A-Za-z_\\\\][A-Za-z0-9_\\\\]{0,159}$/', $class) === 1
313 ? $class
314 : 'class#' . substr(hash('sha256', $class), 0, 12);
315 }
316
317 private static function safePhase(string $phase): string {
318 return in_array($phase, array('initial', 'progress', 'finish'), true)
319 ? $phase
320 : 'unknown';
321 }
322
323 private static function nowFloat(): ?float {
324 if (function_exists('abj_clock')) {
325 return abj_clock()->nowFloat();
326 }
327 if (class_exists('ABJ_404_Solution_SystemClock')) {
328 return (new ABJ_404_Solution_SystemClock())->nowFloat();
329 }
330 return null;
331 }
332
333 private static function elapsedMilliseconds(?float $startedAt): ?int {
334 if ($startedAt === null) {
335 return null;
336 }
337 $finishedAt = self::nowFloat();
338 return $finishedAt === null
339 ? null
340 : max(0, (int)round(($finishedAt - $startedAt) * 1000));
341 }
342
343 private static function reportFailure(string $message): void {
344 abj404_logPhpFallback('cache-metrics-probe', $message);
345 }
346 }
347