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

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

148 lines 6.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 * The immutable request ledger for admin AJAX (Bruno timeout cause matrix,
9 * coverage req. 1).
10 *
11 * One request carries one ID, and that ID has to be recoverable from every
12 * side of the exchange independently: the POST body and query string (so a
13 * proxy or host access log records it), the X-ABJ404-Request-ID request
14 * header, the response header, the response payload, the trace journal, and
15 * the checkpoint file. When a request disappears somewhere between the
16 * browser and PHP, joining those channels on one key is what turns "it timed
17 * out" into "it reached the origin, encoded 41KB, and then never flushed".
18 *
19 * This class owns that identity end to end: the ID format, reading the ledger
20 * fields off the transport, stamping outbound payloads, and recording evidence
21 * when a client or proxy mutates the ID in flight. Participation decisions live
22 * in AjaxDiagnosticRequestPolicy; detach experiment assignment lives in
23 * DetachAbExperiment. It owns no timing, storage, rendering, or experiments.
24 */
25 final class ABJ_404_Solution_AjaxRequestLedger {
26
27 /**
28 * Ledger IDs are alphanumeric and 8-64 characters. Deliberately the same
29 * expression the ajax-update-pagination request contract declares, so a
30 * value that passes schema validation is never rejected here (and one
31 * that skipped validation on a production site -- where the contract
32 * validator is lenient by design -- still cannot get through).
33 */
34 const ID_PATTERN = '/^[A-Za-z0-9]{8,64}$/';
35
36 /** Sentinel for "this request had no usable ID", so the field is never absent. */
37 const UNKNOWN_ID = 'unknown00';
38
39 /**
40 * Normalize a raw ID to the ledger format, degrading anything else to
41 * $fallback. Every channel that reads client input normalizes through
42 * here, so a malformed or hostile value can never be reflected back into
43 * a response, a header, or a journal record, and every channel stays
44 * joinable on the same key.
45 *
46 * @param mixed $raw
47 */
48 public static function normalizeId($raw, string $fallback = self::UNKNOWN_ID): string {
49 $candidate = is_scalar($raw) ? (string)$raw : '';
50 return preg_match(self::ID_PATTERN, $candidate) === 1 ? $candidate : $fallback;
51 }
52
53 /**
54 * Ledger fields that ride the request alongside requestId: the browser
55 * session, the attempt this retry is following up on, the client's send
56 * timestamp (for queue/boot-delta math), the validated request-ID
57 * header, and Cloudflare's own per-request trace ID.
58 *
59 * @param ABJ_404_Solution_RequestInputNormalizer $requestReader Docblock-typed
60 * only (no native parameter type): tests substitute request-reader doubles
61 * that are not literally ABJ_404_Solution_RequestInputNormalizer, and a
62 * native type declaration would TypeError on those at call time.
63 * @return array{session_id: string, retry_parent_id: string, client_sent_at: string, header_request_id: string, cf_ray: string}
64 */
65 public static function readFields($requestReader): array {
66 return array(
67 'session_id' => substr((string)$requestReader->getPostOrGetSanitize('sessionId', ''), 0, 64),
68 'retry_parent_id' => self::normalizeId($requestReader->getPostOrGetSanitize('retryParentId', ''), ''),
69 'client_sent_at' => substr((string)$requestReader->getPostOrGetSanitize('clientSentAt', ''), 0, 64),
70 'header_request_id' => self::readRequestIdHeader(),
71 'cf_ray' => self::readCfRayHeader(),
72 );
73 }
74
75 /**
76 * The client sends X-ABJ404-Request-ID; this validates it. PHP maps that
77 * request header to $_SERVER['HTTP_X_ABJ404_REQUEST_ID'].
78 */
79 public static function readRequestIdHeader(): string {
80 return self::normalizeId($_SERVER['HTTP_X_ABJ404_REQUEST_ID'] ?? '', '');
81 }
82
83 /** Cloudflare's per-request trace ID, captured into the journal when present. */
84 public static function readCfRayHeader(): string {
85 $header = $_SERVER['HTTP_CF_RAY'] ?? '';
86 return is_scalar($header) ? substr((string)$header, 0, 64) : '';
87 }
88
89 /**
90 * A header ID that disagrees with the body ID means something between
91 * the browser and PHP rewrote or replayed the request. That is evidence
92 * about the transport, not noise to silently drop.
93 */
94 public static function recordHeaderMismatchIfAny(string $requestId, string $headerRequestId): void {
95 if ($headerRequestId === '' || $headerRequestId === $requestId) {
96 return;
97 }
98 ABJ_404_Solution_AjaxCheckpointLogger::record($requestId, 'request_id_header_mismatch', array(
99 'header_request_id' => $headerRequestId,
100 ));
101 }
102
103 /**
104 * Normalized ID of the in-flight request, or '' when this request has no
105 * ledger entry at all (a handler that never populated request_id). The
106 * ledger is opt-in per endpoint and a fabricated ID would be worse than
107 * none, so '' means "emit nothing", not "emit the sentinel".
108 *
109 * Unlike instrumentedRequestId() this is NOT gated on the action: the
110 * ledger must be echoed on every response it covers, while checkpoint
111 * file writes stay scoped to the endpoint under investigation.
112 */
113 public static function requestIdFromGlobalContext(): string {
114 $ctx = $GLOBALS['abj404_ajax_context'] ?? null;
115 if (!is_array($ctx) || !array_key_exists('request_id', $ctx)) {
116 return '';
117 }
118 $raw = $ctx['request_id'];
119 if (!is_scalar($raw) || (string)$raw === '') {
120 return '';
121 }
122 return self::normalizeId($raw);
123 }
124
125 /**
126 * Stamp the ledger ID onto an outbound payload that does not already
127 * carry one.
128 *
129 * Applied at the single response choke point rather than at each call
130 * site: the early-response branches (rate-limit 429, auth-failure 403)
131 * are both the ones a stalled request is most likely to hit and the
132 * easiest for a future branch to forget. Stamping centrally makes "an
133 * error response the client cannot join back to its request ID"
134 * impossible by construction instead of by discipline. A payload that
135 * already set its own requestId is left exactly as its handler built it.
136 *
137 * @param mixed $payload
138 * @return mixed
139 */
140 public static function stampOnPayload($payload, string $requestId) {
141 if ($requestId === '' || !is_array($payload) || array_key_exists('requestId', $payload)) {
142 return $payload;
143 }
144 $payload['requestId'] = $requestId;
145 return $payload;
146 }
147 }
148