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

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

353 lines 13.5 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 * Request-scoped durable attribution for external work inside table rows.
9 *
10 * WordPress has no per-callback middleware API: WP_Hook invokes registered
11 * callables directly. Object Cache Pro has a callable tracer, but it is fixed
12 * in WP_REDIS_CONFIG before ordinary plugins load. This adapter therefore
13 * decorates only the live row-render window and restores every changed global
14 * afterward. It never runs outside the instrumented table AJAX endpoint.
15 *
16 * Start/end pairs share one hard record budget. A thrown callback/cache call
17 * deliberately leaves its start unmatched, restores the original runtime
18 * objects, and rethrows the original error unchanged.
19 *
20 * PII: hook names, callback identities, source components, cache keys, and
21 * cache groups are emitted only as conventional safe names or SHA-256
22 * prefixes. Values and callback arguments are never inspected.
23 *
24 * allow-no-test-found: exercised through the real AJAX table render entry point in tests/AjaxRowProgressAttributionTest.php
25 */
26 final class ABJ_404_Solution_RowRenderOperationTracer
27 implements ABJ_404_Solution_CacheOperationTraceSink {
28
29 /** Eight complete operations, with start and end records for each. */
30 const MAX_OPERATION_RECORDS = 16;
31
32 /** @var string */
33 private $requestId;
34 /** @var bool */
35 private $rowActive = false;
36 /** @var bool */
37 private $suspended = false;
38 /** @var bool */
39 private $recording = false;
40 /** @var int */
41 private $recordCount = 0;
42 /** @var int */
43 private $operationSequence = 0;
44 /** @var bool */
45 private $cappedRecorded = false;
46 /** @var bool */
47 private $unavailableHookRecorded = false;
48 /** @var object|null */
49 private $originalCache;
50 /** @var ABJ_404_Solution_InstrumentedObjectCache|null */
51 private $cacheProxy;
52 /** @var ABJ_404_Solution_HookCallbackInstrumenter<array{mode: string, record: array<string, mixed>}|null> */
53 private $hookInstrumenter;
54 /** @var ABJ_404_Solution_HookInstrumentationLifecycleTracer */
55 private $lifecycleTracer;
56
57 public static function begin(string $requestId): self {
58 $tracer = new self($requestId);
59 $tracer->install();
60 return $tracer;
61 }
62
63 private function __construct(string $requestId) {
64 $this->requestId = $requestId;
65 $this->lifecycleTracer = new ABJ_404_Solution_HookInstrumentationLifecycleTracer(
66 $requestId,
67 'row_render'
68 );
69 $this->hookInstrumenter = new ABJ_404_Solution_HookCallbackInstrumenter(
70 function (
71 string $registeredHook,
72 string $actualHook,
73 int $priority,
74 array $identity
75 ) {
76 return $this->beginHookCallback($actualHook, $priority, $identity);
77 },
78 function ($token): void {
79 $this->finishOperation($token);
80 },
81 $this->lifecycleTracer
82 );
83 }
84
85 private function install(): void {
86 $hookBoundary = 'unavailable';
87 $allHookCounts = array(
88 'callbacks_wrapped' => 0,
89 'callbacks_marked' => 0,
90 'callbacks_unavailable' => 0,
91 );
92 if (function_exists('add_filter')) {
93 try {
94 $allHookCounts = $this->hookInstrumenter->instrument('all');
95 if ($allHookCounts['registry_status'] !== 'unavailable') {
96 // The raw add_filter itself traverses and mutates the `all`
97 // registry inside WordPress, after instrument()'s traversal
98 // lifecycle has already closed. Bracket it so a stall inside
99 // registration leaves a durable, reserved boundary rather
100 // than an unattributable hang.
101 $this->lifecycleTracer->traceBoundary(
102 ABJ_404_Solution_HookInstrumentationLifecycleTracer::PHASE_REGISTRATION,
103 'all',
104 function (): void {
105 add_filter('all', array($this, 'prepareHookCallbacks'), PHP_INT_MIN, 1);
106 }
107 );
108 $hookBoundary = 'ready';
109 }
110 } catch (Throwable $e) {
111 self::reportFailure('hook boundary install failed: ' . $e->getMessage());
112 }
113 }
114
115 $cacheBoundary = 'unavailable';
116 $cache = $GLOBALS['wp_object_cache'] ?? null;
117 if (is_object($cache) && !$cache instanceof ABJ_404_Solution_InstrumentedObjectCache) {
118 $this->originalCache = $cache;
119 $this->cacheProxy = new ABJ_404_Solution_InstrumentedObjectCache($cache, $this);
120 $cacheBoundary = 'ready';
121 }
122
123 $this->write('row_operation_instrumentation', array(
124 'hook_boundary' => $hookBoundary,
125 'cache_boundary' => $cacheBoundary,
126 'all_callbacks_wrapped' => $allHookCounts['callbacks_wrapped'],
127 'all_callbacks_marked' => $allHookCounts['callbacks_marked'],
128 'all_callbacks_attributed' => $allHookCounts['callbacks_wrapped']
129 + $allHookCounts['callbacks_marked'],
130 'all_callbacks_unavailable' => $allHookCounts['callbacks_unavailable'],
131 'max_records' => self::MAX_OPERATION_RECORDS,
132 ), false);
133 }
134
135 /** Mark the point after the row checkpoint and before row presentation. */
136 public function enterRow(): void {
137 if (!$this->suspended) {
138 $this->rowActive = true;
139 if ($this->cacheProxy !== null
140 && ($GLOBALS['wp_object_cache'] ?? null) === $this->originalCache) {
141 $GLOBALS['wp_object_cache'] = $this->cacheProxy;
142 }
143 }
144 }
145
146 /** Restore globals after the final row, before aggregate/end checkpoints. */
147 public function finish(): void {
148 $this->rowActive = false;
149 $this->restore();
150 }
151
152 /**
153 * The callback registered on WordPress's `all` hook. It runs before the
154 * named WP_Hook starts, so replacing that hook's callable entries here
155 * does not alter an active specific-hook iteration.
156 *
157 * @param mixed $hookName
158 * @return mixed The original all-hook value, which WordPress ignores.
159 */
160 public function prepareHookCallbacks($hookName) {
161 if (!$this->rowActive || $this->suspended || $this->recording || $this->cappedRecorded
162 || $this->lifecycleTracer->isRecording()
163 || (class_exists('ABJ_404_Solution_AjaxCheckpointLogger')
164 && ABJ_404_Solution_AjaxCheckpointLogger::isRecording())
165 || !is_string($hookName) || $hookName === 'all') {
166 return $hookName;
167 }
168 $counts = $this->hookInstrumenter->instrument($hookName);
169 if ($counts['callbacks_unavailable'] > 0) {
170 $this->recordUnavailableHookOnce($hookName, $counts['callbacks_unavailable']);
171 }
172 return $hookName;
173 }
174
175 /**
176 * @param mixed $key
177 * @param mixed $group
178 * @param callable(): mixed $work
179 * @return mixed
180 */
181 public function traceCache(string $operation, $key, $group, callable $work) {
182 return $this->trace(array(
183 'kind' => 'cache',
184 'operation' => substr(strtolower($operation), 0, 32),
185 'key' => self::hashedValue($key, 'key'),
186 'group' => self::hashedValue($group, 'group'),
187 ), $work);
188 }
189
190 /**
191 * @param array<string, mixed> $fields
192 * @param callable(): mixed $work
193 * @return mixed
194 */
195 private function trace(array $fields, callable $work) {
196 $token = $this->beginOperation($fields);
197 if ($token === null) {
198 return $work();
199 }
200 try {
201 $result = $work();
202 } catch (Throwable $e) {
203 $this->suspended = true;
204 $this->restore(false);
205 throw $e;
206 }
207 $this->finishOperation($token);
208 return $result;
209 }
210
211 /**
212 * @param array{callback: string, source: string, has_reference: bool} $identity
213 * @return array{mode: string, record: array<string, mixed>}|null
214 */
215 private function beginHookCallback(
216 string $actualHook,
217 int $priority,
218 array $identity
219 ): ?array {
220 return $this->beginOperation(array(
221 'kind' => 'hook',
222 'hook' => ABJ_404_Solution_HookCallbackIdentity::hookName($actualHook),
223 'callback' => $identity['callback'],
224 'source' => $identity['source'],
225 'priority' => ABJ_404_Solution_HookCallbackIdentity::jsonSafePriority($priority),
226 ));
227 }
228
229 /**
230 * @param array<string, mixed> $fields
231 * @return array{mode: string, record: array<string, mixed>}|null
232 */
233 private function beginOperation(array $fields): ?array {
234 if (!$this->rowActive || $this->suspended || $this->recording
235 || $this->lifecycleTracer->isRecording()
236 || (class_exists('ABJ_404_Solution_AjaxCheckpointLogger')
237 && ABJ_404_Solution_AjaxCheckpointLogger::isRecording())) {
238 return null;
239 }
240 $operationId = substr(hash(
241 'sha256',
242 $this->requestId . '|' . (++$this->operationSequence) . '|' . serialize($fields)
243 ), 0, 12);
244 $record = array_merge(array('operation_id' => $operationId), $fields);
245 if ($this->recordCount + 2 > self::MAX_OPERATION_RECORDS) {
246 $this->recordCappedOnce();
247 ABJ_404_Solution_AjaxCheckpointLogger::recordActiveOperation(
248 $this->requestId, 'row_operation', 'active', $record);
249 return array('mode' => 'active', 'record' => $record);
250 }
251 $this->write('row_operation_start', $record, true);
252 return array('mode' => 'journal', 'record' => $record);
253 }
254
255 /** @param array{mode: string, record: array<string, mixed>}|null $token */
256 private function finishOperation($token): void {
257 if (!is_array($token)) {
258 return;
259 }
260 if ($token['mode'] === 'active') {
261 ABJ_404_Solution_AjaxCheckpointLogger::recordActiveOperation(
262 $this->requestId,
263 'row_operation',
264 'complete',
265 $token['record']
266 );
267 return;
268 }
269 $this->write('row_operation_end', $token['record'], true);
270 }
271
272 private function recordUnavailableHookOnce(string $hookName, int $count): void {
273 if (!$this->rowActive || $this->unavailableHookRecorded) {
274 return;
275 }
276 $this->unavailableHookRecorded = true;
277 $this->write('row_operation_unavailable', array(
278 'kind' => 'hook',
279 'reason' => 'hook_callback_entry_unavailable',
280 'hook' => ABJ_404_Solution_HookCallbackIdentity::hookName($hookName),
281 'callbacks_unavailable' => $count,
282 ), false);
283 }
284
285 private function recordCappedOnce(): void {
286 if ($this->cappedRecorded) {
287 return;
288 }
289 $this->cappedRecorded = true;
290 $this->write('row_operation_capped', array(
291 'recorded' => $this->recordCount,
292 'max_records' => self::MAX_OPERATION_RECORDS,
293 ), false);
294 }
295
296 /** @param array<string, mixed> $fields */
297 private function write(string $event, array $fields, bool $countsTowardBudget): void {
298 if ($this->requestId === '' || $this->recording) {
299 return;
300 }
301 $this->recording = true;
302 try {
303 ABJ_404_Solution_AjaxCheckpointLogger::recordFrequent($this->requestId, $event, $fields);
304 if ($countsTowardBudget) {
305 $this->recordCount++;
306 }
307 } catch (Throwable $e) {
308 self::reportFailure('operation checkpoint failed: ' . $e->getMessage());
309 } finally {
310 $this->recording = false;
311 }
312 }
313
314 private function restore(bool $scopeCompleted = true): void {
315 if (function_exists('remove_filter')) {
316 try {
317 // Mirror of install(): remove_filter traverses and mutates the
318 // `all` registry before the traversal restore lifecycle begins,
319 // so bracket the atomic removal on its own boundary phase.
320 $this->lifecycleTracer->traceBoundary(
321 ABJ_404_Solution_HookInstrumentationLifecycleTracer::PHASE_REMOVAL,
322 'all',
323 function (): void {
324 remove_filter('all', array($this, 'prepareHookCallbacks'), PHP_INT_MIN);
325 }
326 );
327 } catch (Throwable $e) {
328 self::reportFailure('hook boundary removal failed: ' . $e->getMessage());
329 }
330 }
331 $this->hookInstrumenter->restore($scopeCompleted);
332 if ($this->cacheProxy !== null && ($GLOBALS['wp_object_cache'] ?? null) === $this->cacheProxy) {
333 $GLOBALS['wp_object_cache'] = $this->originalCache;
334 }
335 }
336
337 /** @param mixed $value */
338 private static function hashedValue($value, string $prefix): string {
339 if (is_scalar($value) || $value === null) {
340 $serialized = (string)$value;
341 } elseif (is_array($value)) {
342 $serialized = serialize($value);
343 } else {
344 $serialized = gettype($value);
345 }
346 return $prefix . '#' . substr(hash('sha256', $serialized), 0, 12);
347 }
348
349 private static function reportFailure(string $message): void {
350 abj404_logPhpFallback('row-render-operation', $message);
351 }
352 }
353