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

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

355 lines 12.6 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 * Attributes foreign WordPress callbacks in post-options render scopes.
9 *
10 * The historical record names retain "translation" for support-payload
11 * compatibility. The scope is deliberately broader: an `all` observer runs
12 * before each named WP_Hook and instruments its live callback registry before
13 * WordPress begins that hook. This covers URL, nonce, sanitization, number
14 * formatting, and any future hook reached by the bounded render scope without
15 * another hand-maintained hook list.
16 */
17 final class ABJ_404_Solution_TableRenderTranslationTracer {
18
19 const HOOKS = array(
20 'all',
21 'gettext',
22 'gettext_404-solution',
23 'ngettext',
24 'ngettext_404-solution',
25 );
26 const MAX_CALLBACK_RECORDS = 64;
27
28 /** @var int */
29 private static $scopeSequence = 0;
30 /** @var string */
31 private $requestId;
32 /** @var string */
33 private $phase;
34 /** @var string */
35 private $messageSetHash;
36 /** @var string */
37 private $locale;
38 /** @var string */
39 private $scopeOperationId;
40 /** @var int */
41 private $operationSequence = 0;
42 /** @var int */
43 private $callbackRecordCount = 0;
44 /** @var bool */
45 private $recording = false;
46 /** @var bool */
47 private $capRecorded = false;
48 /** @var bool */
49 private $scopeActive = false;
50 /** @var int */
51 private $callbacksAttributed = 0;
52 /** @var int */
53 private $callbacksUnavailable = 0;
54 /** @var bool */
55 private $registryUnavailable = false;
56 /** @var ABJ_404_Solution_HookCallbackInstrumenter<array{mode: string, fields: array<string, mixed>, started_at?: float|null}|null> */
57 private $hookInstrumenter;
58 /** @var ABJ_404_Solution_HookInstrumentationLifecycleTracer */
59 private $lifecycleTracer;
60
61 /**
62 * @template T
63 * @param callable():T $render
64 * @return T
65 */
66 public static function traceScope(string $phase, string $messageSet, callable $render) {
67 $requestId = class_exists('ABJ_404_Solution_AjaxDiagnosticRequestPolicy')
68 ? ABJ_404_Solution_AjaxDiagnosticRequestPolicy::instrumentedRequestIdFromGlobalContext()
69 : '';
70 if ($requestId === '') {
71 return $render();
72 }
73 return ABJ_404_Solution_RenderOptionIoTracer::traceScope(
74 $requestId,
75 $phase,
76 static function () use ($requestId, $phase, $messageSet, $render) {
77 return (new self($requestId, $phase, $messageSet))->run($render);
78 }
79 );
80 }
81
82 private function __construct(string $requestId, string $phase, string $messageSet) {
83 $this->requestId = $requestId;
84 $this->phase = substr(preg_replace('/[^a-z0-9_]/', '', strtolower($phase)) ?: 'unknown', 0, 48);
85 $this->messageSetHash = substr(hash('sha256', $messageSet), 0, 16);
86 $this->locale = self::localeHint();
87 $this->scopeOperationId = $this->operationId('scope', ++self::$scopeSequence);
88 $this->lifecycleTracer = new ABJ_404_Solution_HookInstrumentationLifecycleTracer(
89 $requestId,
90 'table_render_translation'
91 );
92 $this->hookInstrumenter = new ABJ_404_Solution_HookCallbackInstrumenter(
93 function (
94 string $registeredHook,
95 string $actualHook,
96 int $priority,
97 array $identity,
98 int $callbackOrdinal
99 ) {
100 return $this->beginCallback(
101 $registeredHook,
102 $actualHook,
103 $priority,
104 $callbackOrdinal,
105 $identity
106 );
107 },
108 function ($token): void {
109 $this->finishCallback($token);
110 },
111 $this->lifecycleTracer
112 );
113 }
114
115 /**
116 * @template T
117 * @param callable():T $render
118 * @return T
119 */
120 private function run(callable $render) {
121 $scopeFields = $this->scopeFields();
122 $this->write('render_translation_scope_start', $scopeFields);
123 try {
124 $this->installCallbacks();
125 } catch (Throwable $e) {
126 $this->restoreCallbacks(false);
127 throw $e;
128 }
129 $this->scopeActive = true;
130 $startedAt = self::nowFloat();
131 try {
132 $result = $render();
133 } catch (Throwable $e) {
134 $this->scopeActive = false;
135 $this->restoreCallbacks(false);
136 throw $e;
137 }
138 $this->scopeActive = false;
139 $this->restoreCallbacks(true);
140 $this->write('render_translation_scope_end', array_merge($scopeFields, $this->status(), array(
141 'status' => 'complete',
142 'elapsed_ms' => self::elapsedMilliseconds($startedAt),
143 )));
144 return $result;
145 }
146
147 /**
148 * Install the global observer after wrapping callbacks already registered
149 * directly on `all`. WordPress invokes `all` before the named hook, so the
150 * observer can instrument each named registry just in time.
151 */
152 private function installCallbacks(): void {
153 foreach (self::HOOKS as $hook) {
154 $this->rememberCounts($this->hookInstrumenter->instrument($hook));
155 }
156
157 if (!function_exists('add_filter')) {
158 $this->registryUnavailable = true;
159 return;
160 }
161 $this->lifecycleTracer->traceBoundary(
162 ABJ_404_Solution_HookInstrumentationLifecycleTracer::PHASE_REGISTRATION,
163 'all',
164 function (): void {
165 add_filter('all', array($this, 'prepareHookCallbacks'), PHP_INT_MIN, 1);
166 }
167 );
168 }
169
170 /**
171 * Runs from WordPress's `all` hook before the named hook starts.
172 *
173 * @param mixed $hookName
174 * @return mixed
175 */
176 public function prepareHookCallbacks($hookName) {
177 if (!$this->scopeActive || $this->recording
178 || $this->lifecycleTracer->isRecording()
179 || (class_exists('ABJ_404_Solution_AjaxCheckpointLogger', false)
180 && ABJ_404_Solution_AjaxCheckpointLogger::isRecording())
181 || !is_string($hookName) || $hookName === 'all') {
182 return $hookName;
183 }
184 $this->rememberCounts($this->hookInstrumenter->instrument($hookName));
185 return $hookName;
186 }
187
188 /** @param array<string, int|string> $counts */
189 private function rememberCounts(array $counts): void {
190 $this->callbacksAttributed += (int)($counts['callbacks_wrapped'] ?? 0)
191 + (int)($counts['callbacks_marked'] ?? 0);
192 $this->callbacksUnavailable += (int)($counts['callbacks_unavailable'] ?? 0);
193 if (($counts['registry_status'] ?? '') === 'unavailable') {
194 $this->registryUnavailable = true;
195 }
196 }
197
198 /** @return array{callbacks_attributed: int, callbacks_unavailable: int, registry_status: string} */
199 private function status(): array {
200 return array(
201 'callbacks_attributed' => $this->callbacksAttributed,
202 'callbacks_unavailable' => $this->callbacksUnavailable,
203 'registry_status' => $this->registryUnavailable
204 ? 'unavailable'
205 : ($this->callbacksUnavailable === 0 ? 'ready' : 'partial'),
206 );
207 }
208
209 private function restoreCallbacks(bool $scopeCompleted): void {
210 if (function_exists('remove_filter')) {
211 try {
212 $this->lifecycleTracer->traceBoundary(
213 ABJ_404_Solution_HookInstrumentationLifecycleTracer::PHASE_REMOVAL,
214 'all',
215 function (): void {
216 remove_filter('all', array($this, 'prepareHookCallbacks'), PHP_INT_MIN);
217 }
218 );
219 } catch (Throwable $e) {
220 abj404_logPhpFallback('table-render-translation-tracer', $e->getMessage());
221 }
222 }
223 $this->hookInstrumenter->restore($scopeCompleted);
224 }
225
226 /**
227 * @param array{callback: string, source: string, has_reference: bool} $identity
228 * @return array{mode: string, fields: array<string, mixed>, started_at?: float|null}|null
229 */
230 private function beginCallback(
231 string $registeredHook,
232 string $actualHook,
233 int $priority,
234 int $callbackOrdinal,
235 array $identity
236 ): ?array {
237 if ($this->recording || $this->lifecycleTracer->isRecording()) {
238 return null;
239 }
240 $fields = array_merge($this->scopeFields(), array(
241 'registered_hook' => ABJ_404_Solution_HookCallbackIdentity::hookName($registeredHook),
242 'hook' => ABJ_404_Solution_HookCallbackIdentity::hookName($actualHook),
243 'callback' => $identity['callback'],
244 'source' => $identity['source'],
245 'priority' => ABJ_404_Solution_HookCallbackIdentity::jsonSafePriority($priority),
246 'callback_ordinal' => $callbackOrdinal,
247 'operation_id' => $this->operationId('callback', $callbackOrdinal),
248 ));
249 if ($this->callbackRecordCount + 2 > self::MAX_CALLBACK_RECORDS) {
250 $this->recordCapOnce();
251 ABJ_404_Solution_AjaxCheckpointLogger::recordActiveOperation(
252 $this->requestId,
253 'render_translation_callback',
254 'active',
255 $fields
256 );
257 return array('mode' => 'active', 'fields' => $fields);
258 }
259 $this->write('render_translation_callback_start', $fields);
260 $this->callbackRecordCount++;
261 return array('mode' => 'journal', 'fields' => $fields, 'started_at' => self::nowFloat());
262 }
263
264 /** @param array{mode: string, fields: array<string, mixed>, started_at?: float|null}|null $token */
265 private function finishCallback($token): void {
266 if (!is_array($token)) {
267 return;
268 }
269 if ($token['mode'] === 'active') {
270 ABJ_404_Solution_AjaxCheckpointLogger::recordActiveOperation(
271 $this->requestId,
272 'render_translation_callback',
273 'complete',
274 $token['fields']
275 );
276 return;
277 }
278 $this->write('render_translation_callback_end', array_merge($token['fields'], array(
279 'status' => 'complete',
280 'elapsed_ms' => self::elapsedMilliseconds($token['started_at'] ?? null),
281 )));
282 $this->callbackRecordCount++;
283 }
284
285 private function recordCapOnce(): void {
286 if ($this->capRecorded) {
287 return;
288 }
289 $this->capRecorded = true;
290 $this->write('render_translation_callback_capped', array_merge($this->scopeFields(), array(
291 'recorded' => $this->callbackRecordCount,
292 'max_records' => self::MAX_CALLBACK_RECORDS,
293 )));
294 }
295
296 /** @return array<string, mixed> */
297 private function scopeFields(): array {
298 return array(
299 'operation_id' => $this->scopeOperationId,
300 'phase' => $this->phase,
301 'locale' => $this->locale,
302 'message_set_hash' => $this->messageSetHash,
303 );
304 }
305
306 private function operationId(string $kind, int $ordinal): string {
307 $this->operationSequence++;
308 return substr(hash(
309 'sha256',
310 $this->requestId . '|' . $this->phase . '|' . $kind . '|'
311 . $ordinal . '|' . $this->operationSequence
312 ), 0, 12);
313 }
314
315 /** @param array<string, mixed> $fields */
316 private function write(string $event, array $fields): void {
317 if ($this->recording) {
318 return;
319 }
320 $this->recording = true;
321 try {
322 ABJ_404_Solution_AjaxCheckpointLogger::recordFrequent(
323 $this->requestId,
324 $event,
325 $fields
326 );
327 } catch (Throwable $e) {
328 abj404_logPhpFallback('table-render-translation-tracer', $e->getMessage());
329 } finally {
330 $this->recording = false;
331 }
332 }
333
334 private static function localeHint(): string {
335 $locale = $GLOBALS['locale'] ?? (defined('WPLANG') ? WPLANG : '');
336 return is_string($locale) ? substr($locale, 0, 24) : '';
337 }
338
339 private static function nowFloat(): ?float {
340 if (function_exists('abj_clock')) {
341 return abj_clock()->nowFloat();
342 }
343 return class_exists('ABJ_404_Solution_SystemClock')
344 ? (new ABJ_404_Solution_SystemClock())->nowFloat()
345 : null;
346 }
347
348 private static function elapsedMilliseconds(?float $startedAt): ?int {
349 $now = self::nowFloat();
350 return $startedAt === null || $now === null
351 ? null
352 : max(0, (int)round(($now - $startedAt) * 1000));
353 }
354 }
355