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

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

329 lines 11.4 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 attribution for detach A/B resolution after the response flush.
9 *
10 * The bounded experiment resolves its enablement filter and then reads/writes
11 * a transient attempt counter before the connection-detach call. A foreign
12 * callback, persistent object cache, or database-backed transient can stop
13 * inside any of those boundaries. This tracer lands a two-sink write-ahead
14 * start before resolution and before each transient call, then writes a
15 * matching end only after the operation returns.
16 *
17 * Session ids, transient contents, cache endpoints, callback arguments, and
18 * exception messages never enter the records. The transient key and error
19 * message are one-way fingerprints; cache evidence is limited to selected
20 * backend, class, and public capability availability.
21 */
22 final class ABJ_404_Solution_DetachAbResolutionTracer {
23 /** @var int */
24 private static $operationSequence = 0;
25
26 /**
27 * Bracket the full resolution decision.
28 *
29 * @param callable(): array<string, mixed> $resolution
30 * @return array<string, mixed>
31 */
32 public static function traceResolution(
33 ABJ_404_Solution_DetachAbScope $scope,
34 callable $resolution
35 ): array {
36 $requestId = self::requestId();
37 if ($requestId === '') {
38 return $resolution();
39 }
40 $fields = array(
41 'operation_id' => self::operationId($requestId, 'resolve_detach_ab_mode'),
42 'operation' => 'resolve_detach_ab_mode',
43 'session_state' => $scope->isSessionless() ? 'absent' : 'present',
44 'part' => self::safePart($scope->part()),
45 'payload_key' => self::payloadFingerprint($scope->payloadKey()),
46 );
47 $checkpointId = self::recordStart(
48 $requestId,
49 'detach_ab_resolution_start',
50 $fields
51 );
52 $startedAt = self::nowFloat();
53 try {
54 $result = $resolution();
55 } catch (Throwable $e) {
56 self::recordEnd(
57 $requestId,
58 'detach_ab_resolution_end',
59 $checkpointId,
60 array_merge($fields, self::errorFields($e), array(
61 'status' => 'error',
62 'elapsed_ms' => self::elapsedMilliseconds($startedAt),
63 'counter_status' => 'error',
64 ))
65 );
66 throw $e;
67 }
68 self::recordEnd(
69 $requestId,
70 'detach_ab_resolution_end',
71 $checkpointId,
72 array_merge($fields, array(
73 'status' => 'complete',
74 'elapsed_ms' => self::elapsedMilliseconds($startedAt),
75 'mode' => self::safeMode($result['mode'] ?? null),
76 'diagnostic_enabled' => ($result['diagnostic_enabled'] ?? false) === true,
77 'counter_status' => self::counterStatus($scope, $result),
78 ))
79 );
80 return $result;
81 }
82
83 /**
84 * Bracket one get_transient() or set_transient() call.
85 *
86 * @template T
87 * @param callable(): T $operationCall
88 * @return T
89 */
90 public static function traceTransientOperation(
91 string $operation,
92 string $transientKey,
93 callable $operationCall
94 ) {
95 $requestId = self::requestId();
96 if ($requestId === '') {
97 return $operationCall();
98 }
99 $operation = in_array($operation, array('get_transient', 'set_transient'), true)
100 ? $operation : 'unknown_transient_operation';
101 $fields = array_merge(
102 array(
103 'operation_id' => self::operationId($requestId, $operation),
104 'operation' => $operation,
105 'transient_key' => 'transient#' . substr(hash('sha256', $transientKey), 0, 12),
106 ),
107 self::cacheBackendFields()
108 );
109 $checkpointId = self::recordStart(
110 $requestId,
111 'detach_ab_operation_start',
112 $fields
113 );
114 $startedAt = self::nowFloat();
115 try {
116 $result = $operationCall();
117 } catch (Throwable $e) {
118 self::recordEnd(
119 $requestId,
120 'detach_ab_operation_end',
121 $checkpointId,
122 array_merge($fields, self::errorFields($e), array(
123 'status' => 'error',
124 'elapsed_ms' => self::elapsedMilliseconds($startedAt),
125 ))
126 );
127 throw $e;
128 }
129 self::recordEnd(
130 $requestId,
131 'detach_ab_operation_end',
132 $checkpointId,
133 array_merge($fields, array(
134 'status' => 'complete',
135 'elapsed_ms' => self::elapsedMilliseconds($startedAt),
136 'result' => self::resultSummary($operation, $result),
137 ))
138 );
139 return $result;
140 }
141
142 private static function requestId(): string {
143 if (!class_exists('ABJ_404_Solution_AjaxDiagnosticRequestPolicy')) {
144 return '';
145 }
146 return ABJ_404_Solution_AjaxDiagnosticRequestPolicy::instrumentedRequestIdFromGlobalContext();
147 }
148
149 /** @return array<string, string> */
150 private static function cacheBackendFields(): array {
151 $externalFlag = $GLOBALS['_wp_using_ext_object_cache'] ?? null;
152 $backend = $externalFlag === true
153 ? 'persistent_object_cache'
154 : ($externalFlag === false
155 ? 'database_fallback'
156 : 'undetermined');
157 $cache = $GLOBALS['wp_object_cache'] ?? null;
158 $backendClass = is_object($cache)
159 ? self::safeClassName(get_class($cache))
160 : 'unavailable';
161 $capabilities = array(
162 'external_flag=' . ($externalFlag === null ? 'unknown' : ($externalFlag ? '1' : '0')),
163 'cache_object=' . (is_object($cache) ? '1' : '0'),
164 'wp_using_ext_object_cache=' . (function_exists('wp_using_ext_object_cache') ? '1' : '0'),
165 'wp_cache_get=' . (function_exists('wp_cache_get') ? '1' : '0'),
166 'wp_cache_set=' . (function_exists('wp_cache_set') ? '1' : '0'),
167 'get_transient=' . (function_exists('get_transient') ? '1' : '0'),
168 'set_transient=' . (function_exists('set_transient') ? '1' : '0'),
169 );
170 return array(
171 'cache_backend' => $backend,
172 'cache_backend_class' => $backendClass,
173 'cache_capabilities' => implode(',', $capabilities),
174 );
175 }
176
177 /**
178 * @param mixed $result
179 */
180 private static function resultSummary(string $operation, $result): string {
181 if ($operation === 'get_transient') {
182 if ($result === false) {
183 return 'miss';
184 }
185 return is_numeric($result) ? 'numeric' : 'unexpected_' . self::safeType($result);
186 }
187 if (is_bool($result)) {
188 return $result ? 'success' : 'failure';
189 }
190 return 'unexpected_' . self::safeType($result);
191 }
192
193 /**
194 * @param array<string, mixed> $result
195 */
196 private static function counterStatus(
197 ABJ_404_Solution_DetachAbScope $scope,
198 array $result
199 ): string {
200 if (($result['diagnostic_enabled'] ?? false) !== true) {
201 return 'disabled';
202 }
203 if ($scope->isSessionless()) {
204 return 'no_session';
205 }
206 $attemptIndex = $result['attempt_index'] ?? null;
207 return !is_int($attemptIndex) || $attemptIndex < 0
208 ? 'transient_api_unavailable'
209 : 'attempt_resolved';
210 }
211
212 /** @return array{error_class: string, error_code: int, error: string} */
213 private static function errorFields(Throwable $error): array {
214 $message = $error->getMessage();
215 return array(
216 'error_class' => self::safeClassName(get_class($error)),
217 'error_code' => is_int($error->getCode()) ? $error->getCode() : 0,
218 'error' => 'message#' . substr(hash('sha256', $message), 0, 12),
219 );
220 }
221
222 /** @param mixed $value */
223 private static function safeType($value): string {
224 $type = gettype($value);
225 return preg_match('/^[a-z_]{1,32}$/', $type) === 1 ? $type : 'unknown';
226 }
227
228 /**
229 * The journaled mode, narrowed to the vocabulary the ledger can actually
230 * assign. The list is not repeated here: a fifth mode added to
231 * ABJ_404_Solution_DetachAbAttempt must not silently start tracing as
232 * 'unknown'.
233 *
234 * @param mixed $mode
235 */
236 private static function safeMode($mode): string {
237 return is_string($mode)
238 && in_array($mode, ABJ_404_Solution_DetachAbAttempt::everyMode(), true)
239 ? $mode : 'unknown';
240 }
241
242 private static function safePart(string $part): string {
243 return in_array($part, array('all', 'table', 'counts', 'pagination'), true)
244 ? $part : 'all';
245 }
246
247 private static function payloadFingerprint(string $payloadKey): string {
248 $normalized = preg_match('/^[a-f0-9]{40}$/', $payloadKey) === 1
249 ? $payloadKey
250 : sha1($payloadKey === '' ? 'legacy-payload' : $payloadKey);
251 return 'payload#' . substr(hash('sha256', $normalized), 0, 12);
252 }
253
254 private static function safeClassName(string $class): string {
255 return preg_match('/^[A-Za-z_\\\\][A-Za-z0-9_\\\\]{0,159}$/', $class) === 1
256 ? $class
257 : 'class#' . substr(hash('sha256', $class), 0, 12);
258 }
259
260 private static function operationId(string $requestId, string $operation): string {
261 self::$operationSequence++;
262 return substr(hash(
263 'sha256',
264 $requestId . '|' . $operation . '|' . self::$operationSequence
265 ), 0, 12);
266 }
267
268 /** @param array<string, mixed> $fields */
269 private static function recordStart(
270 string $requestId,
271 string $event,
272 array $fields
273 ): string {
274 try {
275 return ABJ_404_Solution_DurableOperationRecorder::recordStart(
276 $requestId,
277 $event,
278 $fields
279 );
280 } catch (Throwable $e) {
281 self::reportFailure($event . ' write failed: ' . $e->getMessage());
282 return '';
283 }
284 }
285
286 /** @param array<string, mixed> $fields */
287 private static function recordEnd(
288 string $requestId,
289 string $event,
290 string $checkpointId,
291 array $fields
292 ): void {
293 try {
294 ABJ_404_Solution_DurableOperationRecorder::recordEnd(
295 $requestId,
296 $event,
297 $checkpointId,
298 $fields
299 );
300 } catch (Throwable $e) {
301 self::reportFailure($event . ' write failed: ' . $e->getMessage());
302 }
303 }
304
305 private static function nowFloat(): ?float {
306 if (function_exists('abj_clock')) {
307 return abj_clock()->nowFloat();
308 }
309 if (class_exists('ABJ_404_Solution_SystemClock')) {
310 return (new ABJ_404_Solution_SystemClock())->nowFloat();
311 }
312 return null;
313 }
314
315 private static function elapsedMilliseconds(?float $startedAt): ?int {
316 if ($startedAt === null) {
317 return null;
318 }
319 $finishedAt = self::nowFloat();
320 return $finishedAt === null
321 ? null
322 : max(0, (int)round(($finishedAt - $startedAt) * 1000));
323 }
324
325 private static function reportFailure(string $message): void {
326 abj404_logPhpFallback('detach-ab-resolution-tracer', $message);
327 }
328 }
329