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

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

423 lines 19.7 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 * Durable write-ahead journal for one admin AJAX request.
9 *
10 * Each stage -- starting with request_start, the very first flushed write --
11 * is appended and flushed to a request-local pending file before work
12 * starts. Every request is ALWAYS promoted into one bounded, rotated JSONL
13 * journal (matrix coverage req. 4): there is no fast-complete deletion, so a
14 * successful request under the retention threshold cannot silently vanish
15 * the way beta.1's trace did. Rotation is the only bound on retention.
16 * A hard worker kill can skip PHP shutdown, so stale pending files are recovered
17 * into the journal by a later request instead of losing the last started stage.
18 *
19 * This class owns the request's stages while it runs. What happens to the
20 * process AFTER the response is complete belongs to
21 * ABJ_404_Solution_AjaxTeardownRecorder, which the sentinels below delegate to.
22 */
23 final class ABJ_404_Solution_AjaxRequestTrace implements ABJ_404_Solution_DiagnosticInternalHookObserver {
24
25 const SCHEMA_VERSION = 1;
26
27 /** @var array<string, scalar> */
28 private $context;
29 /** @var ABJ_404_Solution_Clock */
30 private $clock;
31 /** @var ABJ_404_Solution_AjaxTraceJournal Durable storage + retention for this request's records. */
32 private $journal;
33 /** @var float */
34 private $requestStartedAt;
35 /** @var float|null */
36 private $stageStartedAt = null;
37 /** @var string */
38 private $currentStage = '';
39 /** @var array<string, scalar> */
40 private $stageMetadata = array();
41 /** @var bool */
42 private $active = true;
43 /** @var float|null Set when finish() runs; lets the teardown recorder measure PHP-shutdown lag after the response was logically complete. */
44 private $responseEmittedAt = null;
45 /** @var ABJ_404_Solution_AjaxTeardownRecorder Owns everything after the response is complete. */
46 private $teardownRecorder;
47 /** @var ABJ_404_Solution_ShutdownCallbackTracer|null */
48 private $shutdownCallbackTracer;
49 /**
50 * Every trace in this process whose PHP-shutdown sentinels are still
51 * armed. The shutdown queue itself already keeps each of these traces
52 * alive until process exit, so this registry adds no retention beyond
53 * what register_shutdown_function() imposes; it exists so the test
54 * harness can retire the sentinels of traces whose "request" (one
55 * PHPUnit test) has already ended.
56 * @var array<int, self>
57 */
58 private static $tracesWithArmedSentinels = array();
59
60 /**
61 * Start tracing for an authorized AJAX request. Failure is non-fatal.
62 *
63 * @param array<string, mixed> $context
64 * @return self|null
65 */
66 public static function start(array $context): ?self {
67 try {
68 $directory = ABJ_404_Solution_DiagnosticDirectoryResolver::resolve($context);
69 if ($directory === '') {
70 self::reportStaticFailure('AJAX trace uploads directory is unavailable.');
71 return null;
72 }
73 if (!ABJ_404_Solution_FileSystemService::createDirectoryWithErrorMessages($directory)) {
74 self::reportStaticFailure('AJAX trace directory could not be created: ' . $directory);
75 return null;
76 }
77 $trace = new self($context, $directory, abj_clock());
78 $trace->journal->recoverAbandoned();
79 // Handler-entry and response-time sentinels preserve evidence if one
80 // shutdown mechanism is skipped. The tracer brackets WordPress's
81 // shutdown action and attributes its callbacks through shared hook
82 // instrumentation. finish() does not disarm them; PHPUnit retires
83 // them between simulated requests via disarmTeardownSentinelsForTests().
84 register_shutdown_function(array($trace, 'recordShutdown'));
85 self::$tracesWithArmedSentinels[] = $trace;
86 if (function_exists('add_action')) {
87 $trace->shutdownCallbackTracer = new ABJ_404_Solution_ShutdownCallbackTracer(
88 (string)($trace->context['request_id'] ?? ''), rtrim($directory, '/\\') . DIRECTORY_SEPARATOR
89 );
90 $trace->shutdownCallbackTracer->registerSentinels(array($trace, 'recordShutdownActionEarly'),
91 array($trace, 'recordShutdownActionLate'));
92 }
93 return $trace;
94 } catch (Throwable $e) {
95 self::reportStaticFailure('AJAX trace initialization failed: ' . $e->getMessage());
96 return null;
97 }
98 }
99
100 /**
101 * @param array<string, mixed> $context
102 */
103 private function __construct(array $context, string $directory, ABJ_404_Solution_Clock $clock) {
104 $this->clock = $clock;
105 $this->requestStartedAt = $clock->nowFloat();
106 $this->context = $this->normalizeContext($context);
107 $stamp = str_replace('.', '', sprintf('%.6f', $this->requestStartedAt));
108 $pendingPath = $directory . 'abj404_ajax_trace_'
109 . $this->context['request_id'] . '_' . $this->context['part'] . '_'
110 . $this->context['retry_count'] . '_'
111 . ABJ_404_Solution_PhpRuntimeCapabilityAdapter::processToken()
112 . '_' . $stamp . '.pending.jsonl';
113 $this->journal = new ABJ_404_Solution_AjaxTraceJournal($directory, $pendingPath, $clock);
114 $this->teardownRecorder = new ABJ_404_Solution_AjaxTeardownRecorder(
115 $clock, $directory, $this->journal);
116
117 // request_start MUST be the first flushed write for this request: it is
118 // the evidence that the trace even started, before any stage runs. If
119 // gathering the full field set itself throws, still flush a minimal
120 // record rather than silently losing the "we got this far" signal.
121 try {
122 $this->appendRecord($this->buildRequestStartRecord($context));
123 } catch (Throwable $e) {
124 $this->appendRecord(array(
125 'event' => 'request_start',
126 'request_start_error' => substr($e->getMessage(), 0, 300),
127 ));
128 }
129 }
130
131 /**
132 * The request_start record: the ledger/event fields this class owns,
133 * merged with the process/build/runtime capture that
134 * ABJ_404_Solution_RequestEnvironmentFingerprint owns.
135 *
136 * @param array<string, mixed> $rawContext
137 * @return array<string, mixed>
138 */
139 private function buildRequestStartRecord(array $rawContext): array {
140 $clientSentAtRaw = $rawContext['client_sent_at'] ?? '';
141 $handlerClassRaw = $rawContext['handler_class'] ?? '';
142 $handlerClass = is_scalar($handlerClassRaw) && (string)$handlerClassRaw !== '' ? (string)$handlerClassRaw : null;
143 $environment = new ABJ_404_Solution_RequestEnvironmentFingerprint($this->clock);
144
145 return array_merge(array(
146 'event' => 'request_start',
147 'client_sent_at' => is_scalar($clientSentAtRaw) ? substr((string)$clientSentAtRaw, 0, 64) : '',
148 ), $environment->capture($handlerClass, 'abj404_trace_probe_' . $this->context['request_id']));
149 }
150
151 /** Begin and flush a stage before its work runs. */
152 public function beginStage(string $stage): void {
153 if (!$this->active) {
154 return;
155 }
156 if ($this->currentStage !== '') {
157 $this->endStage('superseded');
158 }
159 $this->currentStage = substr($stage, 0, 128);
160 $this->stageStartedAt = $this->clock->nowFloat();
161 $this->stageMetadata = array();
162 $this->appendRecord(array('event' => 'stage_start', 'stage' => $this->currentStage));
163 }
164
165 /** @param array<string, scalar> $metadata */
166 public function addStageMetadata(array $metadata): void {
167 if (!$this->active || $this->currentStage === '') {
168 return;
169 }
170 $changed = array();
171 foreach ($metadata as $key => $value) {
172 if (!is_string($key) || !is_scalar($value)) {
173 continue;
174 }
175 if ($key === 'db_timeout_mode') {
176 $value = $this->strongestTimeoutMode((string)($this->stageMetadata[$key] ?? ''), (string)$value);
177 }
178 $value = is_string($value) ? substr($value, 0, 256) : $value;
179 if (array_key_exists($key, $this->stageMetadata) && $this->stageMetadata[$key] === $value) {
180 continue;
181 }
182 $this->stageMetadata[$key] = $value;
183 $changed[$key] = $value;
184 }
185 if ($changed !== array()) {
186 $startedAt = $this->stageStartedAt ?? $this->clock->nowFloat();
187 $this->appendRecord(array_merge(array(
188 'event' => 'stage_metadata',
189 'stage' => $this->currentStage,
190 'elapsed_ms' => max(0, (int)round(($this->clock->nowFloat() - $startedAt) * 1000)),
191 ), $changed));
192 }
193 }
194
195 public function endStage(string $status = 'complete'): void {
196 if (!$this->active || $this->currentStage === '') {
197 return;
198 }
199 $startedAt = $this->stageStartedAt ?? $this->clock->nowFloat();
200 $record = array_merge(array(
201 'event' => 'stage_end',
202 'stage' => $this->currentStage,
203 'status' => substr($status, 0, 32),
204 'elapsed_ms' => max(0, (int)round(($this->clock->nowFloat() - $startedAt) * 1000)),
205 ), $this->stageMetadata);
206 $this->appendRecord($record);
207 $this->currentStage = '';
208 $this->stageStartedAt = null;
209 $this->stageMetadata = array();
210 }
211
212 /**
213 * Complete the request. Promotion uses the journal's bounded try-lock;
214 * on contention the support-readable pending spool stays intact for a
215 * shutdown retry after detachment. Arms a response-time-anchored teardown
216 * sentinel; see recordShutdownAtResponseTime().
217 */
218 public function finish(string $status): void {
219 if (!$this->active) {
220 return;
221 }
222 if ($this->currentStage !== '') {
223 $this->endStage($status === 'complete' ? 'complete' : 'error');
224 }
225 $now = $this->clock->nowFloat();
226 $elapsedMs = max(0, (int)round(($now - $this->requestStartedAt) * 1000));
227 $this->responseEmittedAt = $now;
228 $this->appendRecord(array(
229 'event' => 'request_end',
230 'status' => substr($status, 0, 32),
231 'elapsed_ms' => $elapsedMs,
232 'peak_memory_bytes' => memory_get_peak_usage(true),
233 'connection_aborted' => function_exists('connection_aborted') ? connection_aborted() : 0,
234 ));
235 if ($this->shutdownCallbackTracer !== null) {
236 $this->shutdownCallbackTracer->arm();
237 }
238 $this->active = false;
239 $this->journal->promote();
240 register_shutdown_function(array($this, 'recordShutdownAtResponseTime'));
241 }
242
243 /**
244 * Handler-entry teardown sentinel, armed once in start(). Beta.1's
245 * defect: this no-op'd once finish() had run (`if (!$this->active)
246 * return;`), so a slow sibling shutdown hook or a lingering
247 * client-abort that stalled the worker AFTER the response was handed
248 * off produced zero evidence -- exactly the gap that made beta.1's
249 * trace come back empty. It always writes now; already_finished and
250 * elapsed_since_response_emitted_ms tell a reader whether shutdown ran
251 * promptly after finish() or something held the process open (cause G
252 * in the timeout matrix).
253 */
254 public function recordShutdown(): void {
255 $this->recordTeardown('shutdown',
256 ABJ_404_Solution_AjaxTeardownRecorder::MECHANISM_SHUTDOWN_FUNCTION,
257 ABJ_404_Solution_AjaxTeardownRecorder::ARMED_HANDLER_ENTRY);
258 }
259
260 /** Response-time teardown sentinel; armed a second time in finish(). */
261 public function recordShutdownAtResponseTime(): void {
262 $this->recordTeardown('shutdown_response_time',
263 ABJ_404_Solution_AjaxTeardownRecorder::MECHANISM_SHUTDOWN_FUNCTION,
264 ABJ_404_Solution_AjaxTeardownRecorder::ARMED_RESPONSE_TIME);
265 }
266
267 /**
268 * WP 'shutdown' action at PHP_INT_MIN: the earliest possible read on
269 * shutdown-time state, before any other plugin's own shutdown hook has
270 * had a chance to run. Opens the WordPress-shutdown-action bracket; see
271 * ABJ_404_Solution_ShutdownTeardownBracket.
272 */
273 public function recordShutdownActionEarly(): void {
274 // The earliest point at which post-detach work begins. A census row
275 // still reading this phase minutes later is a worker holding its
276 // process slot while owing the browser nothing -- the shape report 193
277 // showed four times over, and a different failure from one stranded
278 // before the response was delivered.
279 ABJ_404_Solution_SameSiteRequestCensus::markPhase(
280 ABJ_404_Solution_SameSiteRequestCensus::PHASE_SHUTDOWN);
281 ABJ_404_Solution_AjaxStageDiagnostics::recordRequestPhase((string)($this->context['request_id'] ?? ''), 'wordpress_shutdown');
282 $this->teardownRecorder->noteWpActionStart($this->clock->nowFloat());
283 $this->recordTeardown('shutdown_action_min',
284 ABJ_404_Solution_AjaxTeardownRecorder::MECHANISM_WP_ACTION,
285 ABJ_404_Solution_AjaxTeardownRecorder::ARMED_HANDLER_ENTRY);
286 }
287
288 /**
289 * WP 'shutdown' action at PHP_INT_MAX: fires after every other plugin's
290 * default-priority shutdown hook has already run, so it can catch delay
291 * or damage they caused that recordShutdownActionEarly could not see.
292 * Closes the WordPress-shutdown-action bracket.
293 */
294 public function recordShutdownActionLate(): void {
295 $this->teardownRecorder->noteWpActionEnd($this->clock->nowFloat());
296 $this->recordTeardown('shutdown_action_max',
297 ABJ_404_Solution_AjaxTeardownRecorder::MECHANISM_WP_ACTION,
298 ABJ_404_Solution_AjaxTeardownRecorder::ARMED_HANDLER_ENTRY);
299 ABJ_404_Solution_AjaxStageDiagnostics::recordRequestPhase((string)($this->context['request_id'] ?? ''), 'wordpress_shutdown', 'complete');
300 }
301
302 /**
303 * Test-harness end-of-request: mark every armed teardown sentinel in this
304 * process inert.
305 *
306 * In production a trace and its shutdown sentinels live exactly as long
307 * as one request, and the trace directory outlives them both, so the
308 * sentinels are deliberately NEVER disarmed there (see recordShutdown()
309 * for the beta.1 defect that rule replaced) and nothing in production
310 * calls this. A PHPUnit worker breaks the premise the sentinels rely on:
311 * it replays hundreds of requests in one process, each against a
312 * per-test temp directory that tearDown deletes, while
313 * register_shutdown_function() keeps every test's trace queued until the
314 * whole process exits. Without this seam each of those traces flushes
315 * into its deleted directory at process exit and reports 'AJAX trace
316 * file could not be opened' to stderr -- noise that buries real
317 * trace-write failures. Wired into ABJ404_RequestScopedStateReset next
318 * to the request-context resets that exist for the same reason.
319 *
320 * @return void
321 */
322 public static function disarmTeardownSentinelsForTests(): void {
323 foreach (self::$tracesWithArmedSentinels as $trace) {
324 $trace->teardownRecorder->disarm();
325 }
326 self::$tracesWithArmedSentinels = array();
327 }
328
329 /**
330 * Hand this request's state to the teardown recorder and retire the trace
331 * if it wrote.
332 *
333 * The trace stays active when the write did not happen, which is what lets
334 * a later sentinel try again: a retired recorder and a failed write are
335 * both cases where nothing was recorded, and marking the request torn down
336 * on either would discard the one remaining chance to record it.
337 *
338 * @param string $mechanism One of ABJ_404_Solution_AjaxTeardownRecorder's
339 * MECHANISM_* constants.
340 * @param string $armedAt One of its ARMED_* constants.
341 */
342 private function recordTeardown(string $event, string $mechanism, string $armedAt): void {
343 if ($this->teardownRecorder->record($event, $mechanism, $armedAt, array(
344 'envelope' => $this->baseRecord(),
345 'request_started_at' => $this->requestStartedAt,
346 'response_emitted_at' => $this->responseEmittedAt,
347 'already_finished' => !$this->active,
348 'current_stage' => $this->currentStage,
349 ))) {
350 $this->active = false;
351 }
352 }
353
354 /**
355 * Wrap a record in this request's envelope (schema version, timestamp,
356 * ledger context) and hand it to durable storage. Deciding what the
357 * envelope contains is the trace's job; writing it durably is not.
358 *
359 * @param array<string, mixed> $record
360 */
361 private function appendRecord(array $record): void {
362 $this->journal->append(array_merge($this->baseRecord(), $record));
363 }
364
365 /** @return array<string, scalar> */
366 private function baseRecord(): array {
367 return array_merge(array(
368 'schema_version' => self::SCHEMA_VERSION,
369 'ts' => $this->clock->nowFloat(),
370 ), $this->context);
371 }
372
373 /**
374 * @param array<string, mixed> $context
375 * @return array{request_id: string, plugin_version: string, action: string, subpage: string, part: string, retry_count: int}
376 */
377 private function normalizeContext(array $context): array {
378 $part = self::readScalarString($context, 'part', 'all');
379 $retryCountRaw = $context['retry_count'] ?? 0;
380 $retryCount = is_numeric($retryCountRaw) ? (int)$retryCountRaw : 0;
381 return array(
382 // Immutable request ledger (matrix coverage req. 1): the trace journal
383 // is one of the channels a request ID must be recoverable from; the
384 // others are the POST body / query string, the X-ABJ404-Request-ID
385 // request/response headers, and error payloads (Ajax_GetPaginationLinks
386 // + AjaxAdminEndpointSupport). session_id and retry_parent_id ride
387 // along so a retried request can be joined back to its parent attempt.
388 'request_id' => self::readIdField($context, 'request_id', 'unknown00'),
389 'plugin_version' => defined('ABJ404_VERSION') ? (string)ABJ404_VERSION : 'unknown',
390 'action' => substr(self::readScalarString($context, 'action'), 0, 64),
391 'subpage' => substr(self::readScalarString($context, 'subpage'), 0, 64),
392 'part' => substr($part, 0, 32),
393 'retry_count' => max(0, min(2, $retryCount)),
394 'session_id' => substr(self::readScalarString($context, 'session_id'), 0, 64),
395 'retry_parent_id' => self::readIdField($context, 'retry_parent_id', ''),
396 'header_request_id' => self::readIdField($context, 'header_request_id', ''),
397 'cf_ray' => substr(self::readScalarString($context, 'cf_ray'), 0, 64),
398 );
399 }
400
401 /** @param array<string, mixed> $context */
402 private static function readScalarString(array $context, string $key, string $default = ''): string {
403 $raw = $context[$key] ?? $default;
404 return is_scalar($raw) ? (string)$raw : $default;
405 }
406
407 /** @param array<string, mixed> $context */
408 private static function readIdField(array $context, string $key, string $fallback): string {
409 $candidate = self::readScalarString($context, $key);
410 return preg_match('/^[A-Za-z0-9]{8,64}$/', $candidate) === 1 ? $candidate : $fallback;
411 }
412
413 private function strongestTimeoutMode(string $current, string $incoming): string {
414 $rank = array('' => 0, 'none' => 1, 'wrapped' => 2, 'unwrapped' => 3);
415 return ($rank[$incoming] ?? 0) >= ($rank[$current] ?? 0) ? $incoming : $current;
416 }
417
418 private static function reportStaticFailure(string $message): void {
419 // Unconditional; see AjaxCheckpointLogger::reportFailure().
420 abj404_logPhpFallback('ajax-trace', $message);
421 }
422 }
423