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

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

324 lines 10.9 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, request-scoped attribution for rate-limit cache operations.
9 *
10 * The table AJAX handler used to wrap the whole limiter in one checkpoint,
11 * which left backend selection and up to three persistent-cache commands
12 * indistinguishable. This tracer records a bounded start/end pair around
13 * those four fixed operations. The DB fallback is intentionally not wrapped:
14 * its statements already flow through AjaxQueryTimeline.
15 *
16 * Cache keys and groups are hashed before they reach the journal. Backend and
17 * public connection objects are identified only by class and a process-local
18 * object hash; endpoint properties and connection methods are never read.
19 * Thrown errors are summarized safely in the journal and rethrown unchanged.
20 */
21 final class ABJ_404_Solution_RateLimitOperationTracer {
22
23 /** The limiter has one selection plus at most three cache commands. */
24 const MAX_OPERATIONS_PER_CALL = 4;
25
26 /** @var int */
27 private static $operationSequence = 0;
28
29 /** @var string */
30 private static $budgetRequestId = '';
31
32 /** @var int */
33 private static $budgetOperations = 0;
34
35 /**
36 * Trace the cache-vs-DB decision.
37 *
38 * @param callable(): bool $selection
39 */
40 public static function selectBackend(callable $selection): bool {
41 self::beginOperationBudget();
42 return (bool)self::trace('backend_selection', '', '', $selection, true);
43 }
44
45 /**
46 * Trace one persistent object-cache command.
47 *
48 * @param callable(): mixed $command
49 * @return mixed
50 */
51 public static function cacheCommand(
52 string $operation,
53 string $key,
54 string $group,
55 callable $command
56 ) {
57 return self::trace($operation, $key, $group, $command, false);
58 }
59
60 /**
61 * @param callable(): mixed $work
62 * @return mixed
63 */
64 private static function trace(
65 string $operation,
66 string $key,
67 string $group,
68 callable $work,
69 bool $backendSelection
70 ) {
71 $requestId = self::requestId();
72 if ($requestId === '' || !self::claimOperationBudget($requestId)) {
73 return $work();
74 }
75
76 $fields = self::operationFields($requestId, $operation, $key, $group);
77 ABJ_404_Solution_AjaxCheckpointLogger::recordFrequent(
78 $requestId,
79 'rate_limit_operation_start',
80 $fields
81 );
82 $startedAt = self::nowFloat();
83 try {
84 $result = $work();
85 } catch (Throwable $e) {
86 ABJ_404_Solution_AjaxCheckpointLogger::recordFrequent(
87 $requestId,
88 'rate_limit_operation_end',
89 array_merge($fields, array(
90 'status' => 'error',
91 'elapsed_ms' => self::elapsedMilliseconds($startedAt),
92 'error' => self::errorSummary($e),
93 ))
94 );
95 throw $e;
96 }
97 ABJ_404_Solution_AjaxCheckpointLogger::recordFrequent(
98 $requestId,
99 'rate_limit_operation_end',
100 array_merge($fields, array(
101 'status' => 'complete',
102 'elapsed_ms' => self::elapsedMilliseconds($startedAt),
103 'result' => self::resultSummary($result, $backendSelection),
104 ))
105 );
106 return $result;
107 }
108
109 private static function requestId(): string {
110 if (!class_exists('ABJ_404_Solution_AjaxDiagnosticRequestPolicy')) {
111 return '';
112 }
113 return ABJ_404_Solution_AjaxDiagnosticRequestPolicy::instrumentedRequestIdFromGlobalContext();
114 }
115
116 private static function beginOperationBudget(): void {
117 self::$budgetRequestId = self::requestId();
118 self::$budgetOperations = 0;
119 }
120
121 private static function claimOperationBudget(string $requestId): bool {
122 if (self::$budgetRequestId !== $requestId
123 || self::$budgetOperations >= self::MAX_OPERATIONS_PER_CALL) {
124 return false;
125 }
126 self::$budgetOperations++;
127 return true;
128 }
129
130 /**
131 * @return array<string, mixed>
132 */
133 private static function operationFields(
134 string $requestId,
135 string $operation,
136 string $key,
137 string $group
138 ): array {
139 $backend = self::backendSnapshot();
140 $fields = array(
141 'operation_id' => self::operationId($requestId, $operation),
142 'operation' => $operation,
143 'backend_class' => $backend['class'],
144 'capabilities' => $backend['capabilities'],
145 'connection' => $backend['connection'],
146 'max_operations' => self::MAX_OPERATIONS_PER_CALL,
147 );
148 if ($key !== '') {
149 $fields['key'] = self::hashedValue($key, 'key');
150 }
151 if ($group !== '') {
152 $fields['group'] = self::hashedValue($group, 'group');
153 }
154 return $fields;
155 }
156
157 /**
158 * @return array{class: string, capabilities: array<string, bool>, connection: array<string, mixed>}
159 */
160 private static function backendSnapshot(): array {
161 $cache = $GLOBALS['wp_object_cache'] ?? null;
162 $isObject = is_object($cache);
163 return array(
164 'class' => $isObject ? self::safeClassName(get_class($cache)) : 'unavailable',
165 'capabilities' => array(
166 'wp_using_ext_object_cache' => function_exists('wp_using_ext_object_cache'),
167 'wp_cache_add' => function_exists('wp_cache_add'),
168 'wp_cache_incr' => function_exists('wp_cache_incr'),
169 'backend_add' => $isObject && is_callable(array($cache, 'add')),
170 'backend_incr' => $isObject && is_callable(array($cache, 'incr')),
171 ),
172 'connection' => $isObject
173 ? self::connectionIdentity($cache)
174 : array('status' => 'unavailable'),
175 );
176 }
177
178 /**
179 * @return array<string, mixed>
180 */
181 private static function connectionIdentity(object $cache): array {
182 $public = get_object_vars($cache);
183 foreach (array('redis', 'client', 'connection', 'conn', 'memcached', 'mc', 'store') as $name) {
184 if (!array_key_exists($name, $public)) {
185 continue;
186 }
187 $identity = self::identityForValue($public[$name], $name);
188 if ($identity !== null) {
189 return $identity;
190 }
191 }
192 return array('status' => 'unavailable');
193 }
194
195 /**
196 * @param mixed $value
197 * @return array<string, mixed>|null
198 */
199 private static function identityForValue($value, string $source): ?array {
200 if (is_object($value)) {
201 $class = self::safeClassName(get_class($value));
202 return array(
203 'status' => 'available',
204 'source' => $source,
205 'class' => $class,
206 'id' => 'connection#' . substr(
207 hash('sha256', $class . '|' . spl_object_id($value)),
208 0,
209 12
210 ),
211 );
212 }
213 if (is_resource($value)) {
214 $type = get_resource_type($value);
215 return array(
216 'status' => 'available',
217 'source' => $source,
218 'class' => 'resource:' . self::safeToken($type),
219 'id' => 'connection#' . substr(
220 // Casting a resource to int is the PHP 7.4-compatible
221 // resource identity primitive; get_resource_id() starts
222 // at PHP 8.0, above this plugin's supported floor.
223 hash('sha256', $type . '|' . (int)$value),
224 0,
225 12
226 ),
227 );
228 }
229 return null;
230 }
231
232 /**
233 * @param mixed $result
234 * @return array<string, mixed>
235 */
236 private static function resultSummary($result, bool $backendSelection): array {
237 if ($backendSelection) {
238 return array(
239 'type' => 'backend',
240 'value' => $result ? 'persistent_cache' : 'database_fallback',
241 );
242 }
243 if (is_bool($result)) {
244 return array('type' => 'boolean', 'value' => $result);
245 }
246 if (is_int($result)) {
247 return array('type' => 'integer', 'value' => $result);
248 }
249 if (is_float($result)) {
250 return array('type' => 'float', 'value' => $result);
251 }
252 if ($result === null) {
253 return array('type' => 'null', 'value' => null);
254 }
255 if (is_object($result)) {
256 return array('type' => 'object', 'class' => self::safeClassName(get_class($result)));
257 }
258 $encoded = json_encode($result);
259 $encoded = is_string($encoded) ? $encoded : gettype($result);
260 return array(
261 'type' => gettype($result),
262 'value_hash' => substr(hash('sha256', $encoded), 0, 12),
263 'value_length' => strlen($encoded),
264 );
265 }
266
267 /**
268 * @return array<string, mixed>
269 */
270 private static function errorSummary(Throwable $error): array {
271 $message = $error->getMessage();
272 return array(
273 'class' => self::safeClassName(get_class($error)),
274 'code' => is_int($error->getCode()) ? $error->getCode() : 0,
275 'message' => 'message#' . substr(hash('sha256', $message), 0, 12),
276 'message_length' => strlen($message),
277 );
278 }
279
280 private static function operationId(string $requestId, string $operation): string {
281 self::$operationSequence++;
282 return substr(hash(
283 'sha256',
284 $requestId . '|' . $operation . '|' . self::$operationSequence
285 ), 0, 12);
286 }
287
288 private static function hashedValue(string $value, string $kind): string {
289 return $kind . '#' . substr(hash('sha256', $value), 0, 12);
290 }
291
292 private static function safeClassName(string $class): string {
293 return preg_match('/^[A-Za-z_\\\\][A-Za-z0-9_\\\\]{0,159}$/', $class) === 1
294 ? $class
295 : 'class#' . substr(hash('sha256', $class), 0, 12);
296 }
297
298 private static function safeToken(string $value): string {
299 return preg_match('/^[A-Za-z0-9_.:-]{1,64}$/', $value) === 1
300 ? $value
301 : 'token#' . substr(hash('sha256', $value), 0, 12);
302 }
303
304 private static function nowFloat(): ?float {
305 if (function_exists('abj_clock')) {
306 return abj_clock()->nowFloat();
307 }
308 if (class_exists('ABJ_404_Solution_SystemClock')) {
309 return (new ABJ_404_Solution_SystemClock())->nowFloat();
310 }
311 return null;
312 }
313
314 private static function elapsedMilliseconds(?float $startedAt): ?int {
315 if ($startedAt === null) {
316 return null;
317 }
318 $finishedAt = self::nowFloat();
319 return $finishedAt === null
320 ? null
321 : max(0, (int)round(($finishedAt - $startedAt) * 1000));
322 }
323 }
324