PluginProbe
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN / 1.3.7
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN v1.3.7
1.3.7 1.3.6 1.3.5 1.3.4 1.3.3 1.3.2 1.3.1 1.3.0 1.2.4 trunk 1.0.0 1.0.1 1.0.2 1.0.3 1.0.4 1.0.5 1.0.6 1.0.7 1.0.8 1.0.9 1.1.0 1.1.1 1.1.2 1.1.3 1.1.4 All 33 releases
xspeed / includes / class-hit-counter.php

class-hit-counter.php in xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN 1.3.7, at includes/class-hit-counter.php

552 lines 20.5 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Hit_Counter — rolling 24h hits + misses for cache requests.
4 *
5 * Storage: one transient `xspeed_hit_buffer` containing a list of up to
6 * 24 hourly buckets. Each bucket: [hour_start_ts, hits, misses]. Bucket
7 * keyed by floor(time()/3600); old buckets drop off when we push a new
8 * hour. Transient TTL set to 25 hours so an idle site doesn't lose its
9 * history immediately after going quiet.
10 *
11 * Writes happen on every cached HIT and every MISS (Cache.php records
12 * via the static record_* methods). We absorb the cost in an in-process
13 * static accumulator that flushes to the transient once per request via
14 * register_shutdown_function, so the served-from-disk hot path pays
15 * nothing.
16 *
17 * @package XSpeed
18 */
19
20 declare(strict_types=1);
21
22 namespace XSpeed;
23
24 defined( 'ABSPATH' ) || exit;
25
26 final class Hit_Counter {
27
28 public const TRANSIENT_KEY = 'xspeed_hit_buffer';
29 public const TTL = 90000; // 25h
30 public const MAX_BUCKETS = 24;
31
32 /**
33 * Option key holding the bucket buffer.
34 *
35 * Why an OPTION, not a transient (fixed 2026-06-16): with a persistent
36 * object cache absent or misconfigured, `set_transient()` writes to the
37 * object cache ONLY (never the DB) when an external object cache is
38 * "in use" — even if that cache is non-persistent (e.g. xSpeed's own
39 * object-cache drop-in falling back to an in-request array because Redis
40 * isn't reachable). In that state every recorded hit/miss was written to
41 * a per-request cache and discarded at request end, so the dashboard
42 * hit-ratio read 0 (or a meaningless 100% off one drained log line).
43 * Options always persist to wp_options, so the counter survives across
44 * requests regardless of the object-cache backend. read_buffer() also
45 * busts the options-group cache entry before reading so a stale
46 * in-request copy from a non-persistent cache can't shadow the DB value.
47 */
48 public const OPT_KEY = 'xspeed_hit_buffer';
49
50 /** Daily hit/miss aggregates (option, autoload off): 'Y-m-d' => {hits,misses}. */
51 public const DAILY_OPT = 'xspeed_hit_daily';
52
53 /** Days of daily history to retain (the trend UI reads 7/30). */
54 public const DAILY_MAX_DAYS = 120;
55
56 /**
57 * @var array<string,int> Pending increments keyed by metric
58 * ('hit'|'miss'|'excluded'). Flushed on shutdown.
59 * `excluded` = requests that reached the render path
60 * but must NOT count toward cache performance —
61 * 404s and known-bot/scanner traffic (#118).
62 */
63 private static $pending = array(
64 'hit' => 0,
65 'miss' => 0,
66 'excluded' => 0,
67 );
68
69 /**
70 * @var bool Whether the shutdown flush is already registered.
71 */
72 private static $shutdown_registered = false;
73
74 public static function record_hit(): void {
75 ++self::$pending['hit'];
76 self::ensure_shutdown_flush();
77 }
78
79 /**
80 * Record a request that reached the render path but must NOT count toward
81 * the hit ratio — a 404 or known-bot/scanner request. Kept as a separate
82 * line item ("you absorbed N scanner hits today") rather than polluting the
83 * cache-performance denominator, which a wave of `/wp-x7.php` 404s otherwise
84 * craters. Flushed inline like a miss so it's never lost. (#118)
85 */
86 public static function record_excluded(): void {
87 ++self::$pending['excluded'];
88 self::flush_pending();
89 }
90
91 /**
92 * The bot / crawler / scanner alternation, without delimiters so the
93 * drop-in can compose it — see excluded_ua_regex().
94 */
95 public const BOT_UA_PATTERN = 'bot|crawl|spider|slurp|scan|curl|wget|python-requests|python-urllib|libwww|httpclient|go-http|okhttp|axios|node-fetch|headless|phantomjs|masscan|nikto|sqlmap|zgrab|semrush|ahrefs|mj12|dotbot|petalbot|bytespider|facebookexternalhit|preview|monitor|uptime|pingdom|gtmetrix|lighthouse|pagespeed';
96
97 /**
98 * Whether a User-Agent is a known bot / crawler / vulnerability scanner —
99 * its cache misses are cache-warming or hostile noise, not a signal of how
100 * the cache serves real visitors. Deliberately broad: matches the common
101 * crawler tokens plus the generic markers scanners and libraries carry.
102 * Unit-tested; no longer pure — Self_Traffic::is_self() runs the
103 * xspeed_self_user_agents filter, so the answer can vary per site. (#118)
104 */
105 public static function is_bot_ua( string $ua ): bool {
106 if ( '' === $ua ) {
107 // No UA at all is overwhelmingly automated traffic, not a browser.
108 return true;
109 }
110 // Our own warmer, benchmark and verifier are warming the cache, not
111 // visiting it: `xSpeed-Warmer`, `xSpeed Benchmark`, and the rest.
112 // Callers with a request also check Self_Traffic::request_is_marked().
113 if ( Self_Traffic::is_self( $ua ) ) {
114 return true;
115 }
116 return 1 === preg_match( '~(' . self::BOT_UA_PATTERN . ')~i', $ua );
117 }
118
119 /**
120 * The "do not count this user agent" alternation: bots and scanners,
121 * plus the fragments xSpeed's own requests carry. A renamed warmer is
122 * not in it on purpose; that request is recognised by
123 * Self_Traffic::HEADER, because its UA may be a real browser's.
124 *
125 * Baked into the drop-in at install time (`@@XSPEED_HIT_EXCLUDE_RE@@`).
126 * The drop-in runs before WordPress, so it cannot ask this class and the
127 * hits.log line it writes carries no user agent — nothing downstream can
128 * reclassify the line later, which is why the decision has to travel
129 * with the file. A hardcoded copy of the fragments drifted instead: it
130 * excluded the warmer but still counted every crawler HIT, and it could
131 * not know about an overridden `xspeed_preloader_user_agent`.
132 */
133 public static function excluded_ua_regex(): string {
134 $parts = array( self::BOT_UA_PATTERN );
135 foreach ( Self_Traffic::agents() as $agent ) {
136 $parts[] = preg_quote( $agent, '#' );
137 }
138 return implode( '|', $parts );
139 }
140
141 public static function record_miss(): void {
142 ++self::$pending['miss'];
143 // Flush misses INLINE, not at shutdown. A MISS is recorded ONLY here
144 // (HITs additionally have the durable hits.log drain as a backstop),
145 // so if a miss flush is ever dropped the dashboard ratio skews toward
146 // 100%. Flushing inline guarantees the miss is committed to the
147 // options-backed buffer (see OPT_KEY) within this request, before any
148 // shutdown-time object-cache teardown could interfere. Misses are
149 // low-frequency (one per page per cache fill), so the inline write
150 // cost is negligible; HITs stay deferred (high-volume).
151 self::flush_pending();
152 }
153
154 /**
155 * Add `$count` HITs in one shot. Used by collect_nginx_log_hits()
156 * to attribute many HITs served directly by nginx (bypassing PHP)
157 * to the counter once we've drained the log file.
158 */
159 public static function record_hits_batch( int $count ): void {
160 if ( $count <= 0 ) {
161 return;
162 }
163 self::$pending['hit'] += $count;
164 self::ensure_shutdown_flush();
165 }
166
167 /**
168 * Drain the HITs log file at wp-content/cache/xspeed/hits.log. Two
169 * serve paths that can't call record_hit() inline append one line per
170 * HIT here: the nginx server-level rewrite block (see
171 * Cache::nginx_snippet(), serves without ever reaching PHP) and the
172 * advanced-cache.php drop-in (runs before WordPress loads, so
173 * Hit_Counter isn't available). This method reads the line count,
174 * truncates the file, and folds the count into Hit_Counter via
175 * record_hits_batch — so both uncountable-inline paths still show up
176 * in the dashboard hit-ratio on the next load.
177 *
178 * Returns the number of HITs collected (0 if the log is missing,
179 * empty, or the rewrite block isn't engaged).
180 *
181 * Concurrency: file is opened with LOCK_EX before the read/truncate
182 * round-trip so a concurrent nginx write can't lose entries. Nginx
183 * uses buffer=16k flush=10s on its access_log so writes are batched
184 * and the lock contention is negligible.
185 */
186 public static function collect_nginx_log_hits(): int {
187 // Lives under uploads/, not the cache dir — see Cache::hits_log_dir()
188 // (FBS-82478: a cache-dir access_log can take nginx down on purge/
189 // uninstall).
190 $path = Cache::hits_log_path();
191 if ( ! file_exists( $path ) ) {
192 return 0;
193 }
194 if ( filesize( $path ) === 0 ) {
195 return 0;
196 }
197 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen, WordPress.PHP.NoSilencedErrors.Discouraged -- WP_Filesystem doesn't model fopen+flock+ftruncate atomically; we need the lock to prevent nginx writes from being lost.
198 $fp = @fopen( $path, 'r+' );
199 if ( ! $fp ) {
200 return 0;
201 }
202 // Non-blocking exclusive lock — if nginx is mid-write we just skip
203 // this collection and try again on the next dashboard load.
204 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_flock -- See fopen rationale.
205 if ( ! @flock( $fp, LOCK_EX | LOCK_NB ) ) { // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
206 fclose( $fp ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- pairs with the flock'd fopen above; WP_Filesystem can't model flock.
207 return 0;
208 }
209 $count = 0;
210 while ( ( $line = fgets( $fp ) ) !== false ) {
211 if ( '' !== rtrim( $line ) ) {
212 ++$count;
213 }
214 }
215 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_ftruncate -- See fopen rationale.
216 ftruncate( $fp, 0 );
217 flock( $fp, LOCK_UN );
218 fclose( $fp ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- pairs with the flock'd fopen above; WP_Filesystem can't model flock.
219
220 if ( $count > 0 ) {
221 self::record_hits_batch( $count );
222 // Flush immediately — the next read of totals_24h() happens
223 // inline in Cache::get_stats(), before register_shutdown_function
224 // could fire. Without this, the dashboard sees stale numbers
225 // and the just-drained HITs appear on the FOLLOWING refresh.
226 self::flush_pending();
227 }
228 return $count;
229 }
230
231 /** Option key storing the last-scanned byte offset of the access log. */
232 public const SERVER_LOG_OFFSET_OPT = 'xspeed_access_log_offset';
233
234 /**
235 * Count Apache/LiteSpeed static-rewrite HITs by scanning the web
236 * server's access log.
237 *
238 * On Apache/LiteSpeed a cache HIT is served straight from the
239 * `xspeed-static/` tree by a `.htaccess` RewriteRule — the request
240 * never reaches PHP, so (unlike the nginx path, which logs to our own
241 * dedicated hits.log) there's no inline hook to call record_hit().
242 * Instead we read the server's own access log incrementally: every
243 * request whose logged path contains our static-cache dir was a HIT
244 * served below PHP.
245 *
246 * Incremental + safe:
247 * - We remember a byte offset (SERVER_LOG_OFFSET_OPT) and only read
248 * bytes appended since last time — O(new traffic), not O(log size).
249 * - If the log shrank (rotation/truncation) we reset the offset to 0
250 * and rescan from the top once, so a rotation never double-counts
251 * or permanently desyncs.
252 * - We never write to the log, only read; failure is silent.
253 *
254 * Returns 0 (and is a no-op) when no readable access log exists — the
255 * common managed-host case. The drop-in/PHP path still counts its own
256 * HITs, so hit-ratio degrades to "PHP-served hits only" rather than 0.
257 *
258 * @return int HITs folded in this call.
259 */
260 public static function collect_server_log_hits(): int {
261 // Apache only. nginx writes its own dedicated hits.log (drained by
262 // collect_nginx_log_hits); LiteSpeed routes hits through the PHP
263 // drop-in (which also appends to that hits.log) because its
264 // .htaccess can't header/log a static serve — see
265 // Cache::static_rewrite_allowed(). So Apache is the lone server that
266 // serves static hits below PHP yet logs them to the SERVER's access
267 // log, which is what we scan here.
268 //
269 // LiteSpeed stays out even with the Static Fast Path opt-in (#509):
270 // its access log records the ORIGINAL request line ("GET / …"), not
271 // the rewritten static-file path, so the needle below can never
272 // match and scanning would only pretend to count. Verified on
273 // OpenLiteSpeed 1.8. Those hits are genuinely uncounted, which the
274 // dashboard discloses via stats.static_hits_uncounted.
275 if ( Server::APACHE !== Server::type() ) {
276 return 0;
277 }
278
279 $path = Server::access_log_path();
280 if ( '' === $path ) {
281 return 0;
282 }
283
284 $size = @filesize( $path ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- log may vanish on rotation between checks.
285 if ( false === $size ) {
286 return 0;
287 }
288
289 $offset = (int) get_option( self::SERVER_LOG_OFFSET_OPT, 0 );
290 if ( $offset > $size ) {
291 // Log was rotated/truncated since last scan — start over so we
292 // don't seek past EOF and miss the new file's lines.
293 $offset = 0;
294 }
295 if ( $offset === $size ) {
296 return 0; // Nothing new since last drain.
297 }
298
299 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen, WordPress.PHP.NoSilencedErrors.Discouraged -- read-only incremental tail of an external log; WP_Filesystem can't fseek and would buffer the whole file through memory.
300 $fp = @fopen( $path, 'r' );
301 if ( ! $fp ) {
302 return 0;
303 }
304 if ( $offset > 0 ) {
305 fseek( $fp, $offset );
306 }
307
308 // The static-cache dir, as it appears in a logged request path. We
309 // match on the request-target substring so the access-log format
310 // (combined/common/custom) doesn't matter — every format includes
311 // the request line.
312 $needle = '/' . trim( str_replace( ABSPATH, '', XSPEED_CACHE_STATIC_DIR ), '/' );
313 $count = 0;
314 while ( ( $line = fgets( $fp ) ) !== false ) {
315 // Only count GET requests that landed on the static tree. The
316 // "GET " + needle pairing avoids counting our own loopback
317 // probe writes or unrelated dir listings.
318 if ( false !== strpos( $line, $needle ) && false !== strpos( $line, 'GET ' ) ) {
319 ++$count;
320 }
321 }
322 $new_offset = ftell( $fp );
323 fclose( $fp ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- pairs with the read-only fopen above.
324
325 // Persist the offset even when count is 0 so we don't re-scan the
326 // same non-matching bytes every dashboard load.
327 update_option( self::SERVER_LOG_OFFSET_OPT, (int) $new_offset, false );
328
329 if ( $count > 0 ) {
330 self::record_hits_batch( $count );
331 self::flush_pending();
332 }
333 return $count;
334 }
335
336 /**
337 * Returns up to MAX_BUCKETS most-recent hourly buckets oldest →
338 * newest. Each bucket: [ts => unix hour-start, hits => int, misses
339 * => int ].
340 *
341 * @return array<int,array{ts:int,hits:int,misses:int}>
342 */
343 /**
344 * Read the bucket buffer straight from the options table, busting any
345 * stale per-request object-cache copy first so a non-persistent cache
346 * can never shadow the committed DB value. See OPT_KEY docblock.
347 *
348 * @return mixed Raw stored value (array on success).
349 */
350 private static function read_buffer() {
351 // Drop the cached 'options' entry for our key so get_option() falls
352 // through to the DB. Harmless on a persistent cache (it just reloads
353 // from the DB once); essential on a non-persistent one.
354 \wp_cache_delete( self::OPT_KEY, 'options' );
355 return get_option( self::OPT_KEY, array() );
356 }
357
358 private static function write_buffer( array $buf ): void {
359 // Autoload 'no' — the buffer is read only in admin/stats contexts, so
360 // it must never inflate the frontend alloptions payload.
361 if ( false === get_option( self::OPT_KEY, false ) ) {
362 add_option( self::OPT_KEY, $buf, '', 'no' );
363 return;
364 }
365 update_option( self::OPT_KEY, $buf );
366 }
367
368 public static function buckets(): array {
369 $buf = self::read_buffer();
370 if ( ! is_array( $buf ) ) {
371 return array();
372 }
373 // Defensive — strip anything not shaped right.
374 $out = array();
375 foreach ( $buf as $b ) {
376 if ( is_array( $b ) && isset( $b['ts'], $b['hits'], $b['misses'] ) ) {
377 $out[] = array(
378 'ts' => (int) $b['ts'],
379 'hits' => (int) $b['hits'],
380 'misses' => (int) $b['misses'],
381 // Older buckets (pre-#118) have no 'excluded' key — default 0.
382 'excluded' => (int) ( $b['excluded'] ?? 0 ),
383 );
384 }
385 }
386 return $out;
387 }
388
389 /**
390 * Totals over the last 24h (sum across all buckets). `ratio` is computed
391 * over hits + real misses only; `excluded` (404s + bots) is reported
392 * alongside but kept OUT of the denominator so a scanner flood can't crater
393 * the number. (#118)
394 *
395 * @return array{hits:int,misses:int,excluded:int,ratio:float}
396 */
397 public static function totals_24h(): array {
398 $buckets = self::buckets();
399 $hits = 0;
400 $misses = 0;
401 $excluded = 0;
402 foreach ( $buckets as $b ) {
403 $hits += $b['hits'];
404 $misses += $b['misses'];
405 $excluded += $b['excluded'];
406 }
407 $total = $hits + $misses;
408 return array(
409 'hits' => $hits,
410 'misses' => $misses,
411 'excluded' => $excluded,
412 'ratio' => $total > 0 ? round( $hits / $total, 4 ) : 0.0,
413 );
414 }
415
416 public static function reset(): void {
417 delete_transient( self::TRANSIENT_KEY );
418 // The bucket buffer lives in the OPT_KEY option (migrated off the
419 // transient); reset() must clear it too, or record→reset leaves the
420 // old hit/miss buckets behind and buckets() still reports them.
421 delete_option( self::OPT_KEY );
422 \wp_cache_delete( self::OPT_KEY, 'options' );
423 delete_option( self::SERVER_LOG_OFFSET_OPT );
424 delete_option( self::DAILY_OPT );
425 self::$pending = array(
426 'hit' => 0,
427 'miss' => 0,
428 'excluded' => 0,
429 );
430 }
431
432 /**
433 * One-shot register on first record_* call this request.
434 */
435 private static function ensure_shutdown_flush(): void {
436 if ( self::$shutdown_registered ) {
437 return;
438 }
439 self::$shutdown_registered = true;
440 register_shutdown_function( array( __CLASS__, 'flush_pending' ) );
441 }
442
443 /**
444 * Flush in-process counters into the transient. Bucketed by current
445 * hour. New hour → append a bucket and drop the oldest if we exceed
446 * MAX_BUCKETS.
447 */
448 public static function flush_pending(): void {
449 $pending = self::$pending;
450 if ( 0 === $pending['hit'] && 0 === $pending['miss'] && 0 === $pending['excluded'] ) {
451 return;
452 }
453 self::$pending = array(
454 'hit' => 0,
455 'miss' => 0,
456 'excluded' => 0,
457 );
458
459 $hour = (int) ( time() - ( time() % 3600 ) );
460 $buf = self::buckets();
461 $last = end( $buf );
462 $updated = false;
463
464 if ( $last && $last['ts'] === $hour ) {
465 $i = count( $buf ) - 1;
466 $buf[ $i ]['hits'] += $pending['hit'];
467 $buf[ $i ]['misses'] += $pending['miss'];
468 $buf[ $i ]['excluded'] += $pending['excluded'];
469 $updated = true;
470 }
471
472 if ( ! $updated ) {
473 $buf[] = array(
474 'ts' => $hour,
475 'hits' => $pending['hit'],
476 'misses' => $pending['miss'],
477 'excluded' => $pending['excluded'],
478 );
479 while ( count( $buf ) > self::MAX_BUCKETS ) {
480 array_shift( $buf );
481 }
482 }
483
484 self::write_buffer( $buf );
485 self::bump_daily( $pending['hit'], $pending['miss'], $pending['excluded'] );
486 }
487
488 /**
489 * Fold the just-flushed counts into the persistent daily series. The
490 * hourly buckets expire after ~25h; this option is what makes 7/30-day
491 * hit-ratio trends possible (issue #44). Autoload off — it's only read
492 * by the dashboard/REST, never on the frontend hot path.
493 */
494 private static function bump_daily( int $hits, int $misses, int $excluded = 0 ): void {
495 if ( $hits <= 0 && $misses <= 0 && $excluded <= 0 ) {
496 return;
497 }
498 $day = gmdate( 'Y-m-d' );
499 $series = get_option( self::DAILY_OPT, array() );
500 if ( ! is_array( $series ) ) {
501 $series = array();
502 }
503 if ( ! isset( $series[ $day ] ) || ! is_array( $series[ $day ] ) ) {
504 $series[ $day ] = array(
505 'hits' => 0,
506 'misses' => 0,
507 'excluded' => 0,
508 );
509 }
510 $series[ $day ]['hits'] += $hits;
511 $series[ $day ]['misses'] += $misses;
512 $series[ $day ]['excluded'] = (int) ( $series[ $day ]['excluded'] ?? 0 ) + $excluded;
513 if ( count( $series ) > self::DAILY_MAX_DAYS ) {
514 ksort( $series );
515 $series = array_slice( $series, -self::DAILY_MAX_DAYS, null, true );
516 }
517 update_option( self::DAILY_OPT, $series, false );
518 }
519
520 /**
521 * The stored daily hit/miss series, oldest→newest, at most $days rows.
522 *
523 * @return array<int,array{date:string,hits:int,misses:int,ratio:float}>
524 */
525 public static function daily_series( int $days = 30 ): array {
526 $series = get_option( self::DAILY_OPT, array() );
527 if ( ! is_array( $series ) || empty( $series ) ) {
528 return array();
529 }
530 ksort( $series );
531 $series = array_slice( $series, -max( 1, $days ), null, true );
532 $out = array();
533 foreach ( $series as $date => $row ) {
534 if ( ! is_array( $row ) ) {
535 continue;
536 }
537 $hits = (int) ( $row['hits'] ?? 0 );
538 $misses = (int) ( $row['misses'] ?? 0 );
539 $excluded = (int) ( $row['excluded'] ?? 0 );
540 $total = $hits + $misses;
541 $out[] = array(
542 'date' => (string) $date,
543 'hits' => $hits,
544 'misses' => $misses,
545 'excluded' => $excluded,
546 'ratio' => $total > 0 ? round( $hits / $total, 4 ) : 0.0,
547 );
548 }
549 return $out;
550 }
551 }
552