PluginProbe
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN / 1.2.0
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN v1.2.0
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 1.1.5 1.1.6 1.1.7 1.1.8 1.2.0 All 28 releases
xspeed / includes / class-hit-counter.php

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

513 lines 18.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 * Whether a User-Agent is a known bot / crawler / vulnerability scanner —
93 * its cache misses are cache-warming or hostile noise, not a signal of how
94 * the cache serves real visitors. Deliberately broad: matches the common
95 * crawler tokens plus the generic markers scanners and libraries carry.
96 * Pure + unit-tested. (#118)
97 */
98 public static function is_bot_ua( string $ua ): bool {
99 if ( '' === $ua ) {
100 // No UA at all is overwhelmingly automated traffic, not a browser.
101 return true;
102 }
103 return 1 === preg_match(
104 '~(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)~i',
105 $ua
106 );
107 }
108
109 public static function record_miss(): void {
110 ++self::$pending['miss'];
111 // Flush misses INLINE, not at shutdown. A MISS is recorded ONLY here
112 // (HITs additionally have the durable hits.log drain as a backstop),
113 // so if a miss flush is ever dropped the dashboard ratio skews toward
114 // 100%. Flushing inline guarantees the miss is committed to the
115 // options-backed buffer (see OPT_KEY) within this request, before any
116 // shutdown-time object-cache teardown could interfere. Misses are
117 // low-frequency (one per page per cache fill), so the inline write
118 // cost is negligible; HITs stay deferred (high-volume).
119 self::flush_pending();
120 }
121
122 /**
123 * Add `$count` HITs in one shot. Used by collect_nginx_log_hits()
124 * to attribute many HITs served directly by nginx (bypassing PHP)
125 * to the counter once we've drained the log file.
126 */
127 public static function record_hits_batch( int $count ): void {
128 if ( $count <= 0 ) {
129 return;
130 }
131 self::$pending['hit'] += $count;
132 self::ensure_shutdown_flush();
133 }
134
135 /**
136 * Drain the HITs log file at wp-content/cache/xspeed/hits.log. Two
137 * serve paths that can't call record_hit() inline append one line per
138 * HIT here: the nginx server-level rewrite block (see
139 * Cache::nginx_snippet(), serves without ever reaching PHP) and the
140 * advanced-cache.php drop-in (runs before WordPress loads, so
141 * Hit_Counter isn't available). This method reads the line count,
142 * truncates the file, and folds the count into Hit_Counter via
143 * record_hits_batch — so both uncountable-inline paths still show up
144 * in the dashboard hit-ratio on the next load.
145 *
146 * Returns the number of HITs collected (0 if the log is missing,
147 * empty, or the rewrite block isn't engaged).
148 *
149 * Concurrency: file is opened with LOCK_EX before the read/truncate
150 * round-trip so a concurrent nginx write can't lose entries. Nginx
151 * uses buffer=16k flush=10s on its access_log so writes are batched
152 * and the lock contention is negligible.
153 */
154 public static function collect_nginx_log_hits(): int {
155 // Lives under uploads/, not the cache dir — see Cache::hits_log_dir()
156 // (FBS-82478: a cache-dir access_log can take nginx down on purge/
157 // uninstall).
158 $path = Cache::hits_log_path();
159 if ( ! file_exists( $path ) ) {
160 return 0;
161 }
162 if ( filesize( $path ) === 0 ) {
163 return 0;
164 }
165 // 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.
166 $fp = @fopen( $path, 'r+' );
167 if ( ! $fp ) {
168 return 0;
169 }
170 // Non-blocking exclusive lock — if nginx is mid-write we just skip
171 // this collection and try again on the next dashboard load.
172 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_flock -- See fopen rationale.
173 if ( ! @flock( $fp, LOCK_EX | LOCK_NB ) ) { // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
174 fclose( $fp ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- pairs with the flock'd fopen above; WP_Filesystem can't model flock.
175 return 0;
176 }
177 $count = 0;
178 while ( ( $line = fgets( $fp ) ) !== false ) {
179 if ( '' !== rtrim( $line ) ) {
180 ++$count;
181 }
182 }
183 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_ftruncate -- See fopen rationale.
184 ftruncate( $fp, 0 );
185 flock( $fp, LOCK_UN );
186 fclose( $fp ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- pairs with the flock'd fopen above; WP_Filesystem can't model flock.
187
188 if ( $count > 0 ) {
189 self::record_hits_batch( $count );
190 // Flush immediately — the next read of totals_24h() happens
191 // inline in Cache::get_stats(), before register_shutdown_function
192 // could fire. Without this, the dashboard sees stale numbers
193 // and the just-drained HITs appear on the FOLLOWING refresh.
194 self::flush_pending();
195 }
196 return $count;
197 }
198
199 /** Option key storing the last-scanned byte offset of the access log. */
200 public const SERVER_LOG_OFFSET_OPT = 'xspeed_access_log_offset';
201
202 /**
203 * Count Apache/LiteSpeed static-rewrite HITs by scanning the web
204 * server's access log.
205 *
206 * On Apache/LiteSpeed a cache HIT is served straight from the
207 * `xspeed-static/` tree by a `.htaccess` RewriteRule — the request
208 * never reaches PHP, so (unlike the nginx path, which logs to our own
209 * dedicated hits.log) there's no inline hook to call record_hit().
210 * Instead we read the server's own access log incrementally: every
211 * request whose logged path contains our static-cache dir was a HIT
212 * served below PHP.
213 *
214 * Incremental + safe:
215 * - We remember a byte offset (SERVER_LOG_OFFSET_OPT) and only read
216 * bytes appended since last time — O(new traffic), not O(log size).
217 * - If the log shrank (rotation/truncation) we reset the offset to 0
218 * and rescan from the top once, so a rotation never double-counts
219 * or permanently desyncs.
220 * - We never write to the log, only read; failure is silent.
221 *
222 * Returns 0 (and is a no-op) when no readable access log exists — the
223 * common managed-host case. The drop-in/PHP path still counts its own
224 * HITs, so hit-ratio degrades to "PHP-served hits only" rather than 0.
225 *
226 * @return int HITs folded in this call.
227 */
228 public static function collect_server_log_hits(): int {
229 // Apache only. nginx writes its own dedicated hits.log (drained by
230 // collect_nginx_log_hits); LiteSpeed routes hits through the PHP
231 // drop-in (which also appends to that hits.log) because its
232 // .htaccess can't header/log a static serve — see
233 // Cache::static_rewrite_allowed(). So Apache is the lone server that
234 // serves static hits below PHP yet logs them to the SERVER's access
235 // log, which is what we scan here.
236 if ( Server::APACHE !== Server::type() ) {
237 return 0;
238 }
239
240 $path = Server::access_log_path();
241 if ( '' === $path ) {
242 return 0;
243 }
244
245 $size = @filesize( $path ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- log may vanish on rotation between checks.
246 if ( false === $size ) {
247 return 0;
248 }
249
250 $offset = (int) get_option( self::SERVER_LOG_OFFSET_OPT, 0 );
251 if ( $offset > $size ) {
252 // Log was rotated/truncated since last scan — start over so we
253 // don't seek past EOF and miss the new file's lines.
254 $offset = 0;
255 }
256 if ( $offset === $size ) {
257 return 0; // Nothing new since last drain.
258 }
259
260 // 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.
261 $fp = @fopen( $path, 'r' );
262 if ( ! $fp ) {
263 return 0;
264 }
265 if ( $offset > 0 ) {
266 fseek( $fp, $offset );
267 }
268
269 // The static-cache dir, as it appears in a logged request path. We
270 // match on the request-target substring so the access-log format
271 // (combined/common/custom) doesn't matter — every format includes
272 // the request line.
273 $needle = '/' . trim( str_replace( ABSPATH, '', XSPEED_CACHE_STATIC_DIR ), '/' );
274 $count = 0;
275 while ( ( $line = fgets( $fp ) ) !== false ) {
276 // Only count GET requests that landed on the static tree. The
277 // "GET " + needle pairing avoids counting our own loopback
278 // probe writes or unrelated dir listings.
279 if ( false !== strpos( $line, $needle ) && false !== strpos( $line, 'GET ' ) ) {
280 ++$count;
281 }
282 }
283 $new_offset = ftell( $fp );
284 fclose( $fp ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- pairs with the read-only fopen above.
285
286 // Persist the offset even when count is 0 so we don't re-scan the
287 // same non-matching bytes every dashboard load.
288 update_option( self::SERVER_LOG_OFFSET_OPT, (int) $new_offset, false );
289
290 if ( $count > 0 ) {
291 self::record_hits_batch( $count );
292 self::flush_pending();
293 }
294 return $count;
295 }
296
297 /**
298 * Returns up to MAX_BUCKETS most-recent hourly buckets oldest →
299 * newest. Each bucket: [ts => unix hour-start, hits => int, misses
300 * => int ].
301 *
302 * @return array<int,array{ts:int,hits:int,misses:int}>
303 */
304 /**
305 * Read the bucket buffer straight from the options table, busting any
306 * stale per-request object-cache copy first so a non-persistent cache
307 * can never shadow the committed DB value. See OPT_KEY docblock.
308 *
309 * @return mixed Raw stored value (array on success).
310 */
311 private static function read_buffer() {
312 // Drop the cached 'options' entry for our key so get_option() falls
313 // through to the DB. Harmless on a persistent cache (it just reloads
314 // from the DB once); essential on a non-persistent one.
315 \wp_cache_delete( self::OPT_KEY, 'options' );
316 return get_option( self::OPT_KEY, array() );
317 }
318
319 private static function write_buffer( array $buf ): void {
320 // Autoload 'no' — the buffer is read only in admin/stats contexts, so
321 // it must never inflate the frontend alloptions payload.
322 if ( false === get_option( self::OPT_KEY, false ) ) {
323 add_option( self::OPT_KEY, $buf, '', 'no' );
324 return;
325 }
326 update_option( self::OPT_KEY, $buf );
327 }
328
329 public static function buckets(): array {
330 $buf = self::read_buffer();
331 if ( ! is_array( $buf ) ) {
332 return array();
333 }
334 // Defensive — strip anything not shaped right.
335 $out = array();
336 foreach ( $buf as $b ) {
337 if ( is_array( $b ) && isset( $b['ts'], $b['hits'], $b['misses'] ) ) {
338 $out[] = array(
339 'ts' => (int) $b['ts'],
340 'hits' => (int) $b['hits'],
341 'misses' => (int) $b['misses'],
342 // Older buckets (pre-#118) have no 'excluded' key — default 0.
343 'excluded' => (int) ( $b['excluded'] ?? 0 ),
344 );
345 }
346 }
347 return $out;
348 }
349
350 /**
351 * Totals over the last 24h (sum across all buckets). `ratio` is computed
352 * over hits + real misses only; `excluded` (404s + bots) is reported
353 * alongside but kept OUT of the denominator so a scanner flood can't crater
354 * the number. (#118)
355 *
356 * @return array{hits:int,misses:int,excluded:int,ratio:float}
357 */
358 public static function totals_24h(): array {
359 $buckets = self::buckets();
360 $hits = 0;
361 $misses = 0;
362 $excluded = 0;
363 foreach ( $buckets as $b ) {
364 $hits += $b['hits'];
365 $misses += $b['misses'];
366 $excluded += $b['excluded'];
367 }
368 $total = $hits + $misses;
369 return array(
370 'hits' => $hits,
371 'misses' => $misses,
372 'excluded' => $excluded,
373 'ratio' => $total > 0 ? round( $hits / $total, 4 ) : 0.0,
374 );
375 }
376
377 public static function reset(): void {
378 delete_transient( self::TRANSIENT_KEY );
379 // The bucket buffer lives in the OPT_KEY option (migrated off the
380 // transient); reset() must clear it too, or record→reset leaves the
381 // old hit/miss buckets behind and buckets() still reports them.
382 delete_option( self::OPT_KEY );
383 \wp_cache_delete( self::OPT_KEY, 'options' );
384 delete_option( self::SERVER_LOG_OFFSET_OPT );
385 delete_option( self::DAILY_OPT );
386 self::$pending = array(
387 'hit' => 0,
388 'miss' => 0,
389 'excluded' => 0,
390 );
391 }
392
393 /**
394 * One-shot register on first record_* call this request.
395 */
396 private static function ensure_shutdown_flush(): void {
397 if ( self::$shutdown_registered ) {
398 return;
399 }
400 self::$shutdown_registered = true;
401 register_shutdown_function( array( __CLASS__, 'flush_pending' ) );
402 }
403
404 /**
405 * Flush in-process counters into the transient. Bucketed by current
406 * hour. New hour → append a bucket and drop the oldest if we exceed
407 * MAX_BUCKETS.
408 */
409 public static function flush_pending(): void {
410 $pending = self::$pending;
411 if ( 0 === $pending['hit'] && 0 === $pending['miss'] && 0 === $pending['excluded'] ) {
412 return;
413 }
414 self::$pending = array(
415 'hit' => 0,
416 'miss' => 0,
417 'excluded' => 0,
418 );
419
420 $hour = (int) ( time() - ( time() % 3600 ) );
421 $buf = self::buckets();
422 $last = end( $buf );
423 $updated = false;
424
425 if ( $last && $last['ts'] === $hour ) {
426 $i = count( $buf ) - 1;
427 $buf[ $i ]['hits'] += $pending['hit'];
428 $buf[ $i ]['misses'] += $pending['miss'];
429 $buf[ $i ]['excluded'] += $pending['excluded'];
430 $updated = true;
431 }
432
433 if ( ! $updated ) {
434 $buf[] = array(
435 'ts' => $hour,
436 'hits' => $pending['hit'],
437 'misses' => $pending['miss'],
438 'excluded' => $pending['excluded'],
439 );
440 while ( count( $buf ) > self::MAX_BUCKETS ) {
441 array_shift( $buf );
442 }
443 }
444
445 self::write_buffer( $buf );
446 self::bump_daily( $pending['hit'], $pending['miss'], $pending['excluded'] );
447 }
448
449 /**
450 * Fold the just-flushed counts into the persistent daily series. The
451 * hourly buckets expire after ~25h; this option is what makes 7/30-day
452 * hit-ratio trends possible (issue #44). Autoload off — it's only read
453 * by the dashboard/REST, never on the frontend hot path.
454 */
455 private static function bump_daily( int $hits, int $misses, int $excluded = 0 ): void {
456 if ( $hits <= 0 && $misses <= 0 && $excluded <= 0 ) {
457 return;
458 }
459 $day = gmdate( 'Y-m-d' );
460 $series = get_option( self::DAILY_OPT, array() );
461 if ( ! is_array( $series ) ) {
462 $series = array();
463 }
464 if ( ! isset( $series[ $day ] ) || ! is_array( $series[ $day ] ) ) {
465 $series[ $day ] = array(
466 'hits' => 0,
467 'misses' => 0,
468 'excluded' => 0,
469 );
470 }
471 $series[ $day ]['hits'] += $hits;
472 $series[ $day ]['misses'] += $misses;
473 $series[ $day ]['excluded'] = (int) ( $series[ $day ]['excluded'] ?? 0 ) + $excluded;
474 if ( count( $series ) > self::DAILY_MAX_DAYS ) {
475 ksort( $series );
476 $series = array_slice( $series, -self::DAILY_MAX_DAYS, null, true );
477 }
478 update_option( self::DAILY_OPT, $series, false );
479 }
480
481 /**
482 * The stored daily hit/miss series, oldest→newest, at most $days rows.
483 *
484 * @return array<int,array{date:string,hits:int,misses:int,ratio:float}>
485 */
486 public static function daily_series( int $days = 30 ): array {
487 $series = get_option( self::DAILY_OPT, array() );
488 if ( ! is_array( $series ) || empty( $series ) ) {
489 return array();
490 }
491 ksort( $series );
492 $series = array_slice( $series, -max( 1, $days ), null, true );
493 $out = array();
494 foreach ( $series as $date => $row ) {
495 if ( ! is_array( $row ) ) {
496 continue;
497 }
498 $hits = (int) ( $row['hits'] ?? 0 );
499 $misses = (int) ( $row['misses'] ?? 0 );
500 $excluded = (int) ( $row['excluded'] ?? 0 );
501 $total = $hits + $misses;
502 $out[] = array(
503 'date' => (string) $date,
504 'hits' => $hits,
505 'misses' => $misses,
506 'excluded' => $excluded,
507 'ratio' => $total > 0 ? round( $hits / $total, 4 ) : 0.0,
508 );
509 }
510 return $out;
511 }
512 }
513