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
← All changes | includes/class-hit-counter.php +139 -40 1.1.1 → 1.3.7 View file →
@@ -53,12 +53,19 @@
53 53 /** Days of daily history to retain (the trend UI reads 7/30). */
54 54 public const DAILY_MAX_DAYS = 120;
55 55
56 56 /**
57 - * @var array<int,int> Pending increments keyed by metric ('hit'|'miss').
58 - * Flushed to the transient on shutdown.
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).
59 62 */
60 - private static $pending = array( 'hit' => 0, 'miss' => 0 );
63 + private static $pending = array(
64 + 'hit' => 0,
65 + 'miss' => 0,
66 + 'excluded' => 0,
67 + );
61 68
62 69 /**
63 70 * @var bool Whether the shutdown flush is already registered.
64 71 */
@@ -68,8 +75,70 @@
68 75 ++self::$pending['hit'];
69 76 self::ensure_shutdown_flush();
70 77 }
71 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 +
72 141 public static function record_miss(): void {
73 142 ++self::$pending['miss'];
74 143 // Flush misses INLINE, not at shutdown. A MISS is recorded ONLY here
75 144 // (HITs additionally have the durable hits.log drain as a backstop),
@@ -195,8 +264,15 @@
195 264 // .htaccess can't header/log a static serve — see
196 265 // Cache::static_rewrite_allowed(). So Apache is the lone server that
197 266 // serves static hits below PHP yet logs them to the SERVER's access
198 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.
199 275 if ( Server::APACHE !== Server::type() ) {
200 276 return 0;
201 277 }
202 278
@@ -298,11 +374,13 @@
298 374 $out = array();
299 375 foreach ( $buf as $b ) {
300 376 if ( is_array( $b ) && isset( $b['ts'], $b['hits'], $b['misses'] ) ) {
301 377 $out[] = array(
302 - 'ts' => (int) $b['ts'],
303 - 'hits' => (int) $b['hits'],
304 - 'misses' => (int) $b['misses'],
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 ),
305 383 );
306 384 }
307 385 }
308 386 return $out;
@@ -308,25 +386,31 @@
308 386 return $out;
309 387 }
310 388
311 389 /**
312 - * Totals over the last 24h (sum across all buckets).
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)
313 394 *
314 - * @return array{hits:int,misses:int,ratio:float}
395 + * @return array{hits:int,misses:int,excluded:int,ratio:float}
315 396 */
316 397 public static function totals_24h(): array {
317 - $buckets = self::buckets();
318 - $hits = 0;
319 - $misses = 0;
398 + $buckets = self::buckets();
399 + $hits = 0;
400 + $misses = 0;
401 + $excluded = 0;
320 402 foreach ( $buckets as $b ) {
321 - $hits += $b['hits'];
322 - $misses += $b['misses'];
403 + $hits += $b['hits'];
404 + $misses += $b['misses'];
405 + $excluded += $b['excluded'];
323 406 }
324 407 $total = $hits + $misses;
325 408 return array(
326 - 'hits' => $hits,
327 - 'misses' => $misses,
328 - 'ratio' => $total > 0 ? round( $hits / $total, 4 ) : 0.0,
409 + 'hits' => $hits,
410 + 'misses' => $misses,
411 + 'excluded' => $excluded,
412 + 'ratio' => $total > 0 ? round( $hits / $total, 4 ) : 0.0,
329 413 );
330 414 }
331 415
332 416 public static function reset(): void {
@@ -337,9 +421,13 @@
337 421 delete_option( self::OPT_KEY );
338 422 \wp_cache_delete( self::OPT_KEY, 'options' );
339 423 delete_option( self::SERVER_LOG_OFFSET_OPT );
340 424 delete_option( self::DAILY_OPT );
341 - self::$pending = array( 'hit' => 0, 'miss' => 0 );
425 + self::$pending = array(
426 + 'hit' => 0,
427 + 'miss' => 0,
428 + 'excluded' => 0,
429 + );
342 430 }
343 431
344 432 /**
345 433 * One-shot register on first record_* call this request.
@@ -358,12 +446,16 @@
358 446 * MAX_BUCKETS.
359 447 */
360 448 public static function flush_pending(): void {
361 449 $pending = self::$pending;
362 - if ( 0 === $pending['hit'] && 0 === $pending['miss'] ) {
450 + if ( 0 === $pending['hit'] && 0 === $pending['miss'] && 0 === $pending['excluded'] ) {
363 451 return;
364 452 }
365 - self::$pending = array( 'hit' => 0, 'miss' => 0 );
453 + self::$pending = array(
454 + 'hit' => 0,
455 + 'miss' => 0,
456 + 'excluded' => 0,
457 + );
366 458
367 459 $hour = (int) ( time() - ( time() % 3600 ) );
368 460 $buf = self::buckets();
369 461 $last = end( $buf );
@@ -369,18 +461,21 @@
369 461 $last = end( $buf );
370 462 $updated = false;
371 463
372 464 if ( $last && $last['ts'] === $hour ) {
373 - $buf[ count( $buf ) - 1 ]['hits'] += $pending['hit'];
374 - $buf[ count( $buf ) - 1 ]['misses'] += $pending['miss'];
375 - $updated = true;
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;
376 470 }
377 471
378 472 if ( ! $updated ) {
379 473 $buf[] = array(
380 - 'ts' => $hour,
381 - 'hits' => $pending['hit'],
382 - 'misses' => $pending['miss'],
474 + 'ts' => $hour,
475 + 'hits' => $pending['hit'],
476 + 'misses' => $pending['miss'],
477 + 'excluded' => $pending['excluded'],
383 478 );
384 479 while ( count( $buf ) > self::MAX_BUCKETS ) {
385 480 array_shift( $buf );
386 481 }
@@ -386,9 +481,9 @@
386 481 }
387 482 }
388 483
389 484 self::write_buffer( $buf );
390 - self::bump_daily( $pending['hit'], $pending['miss'] );
485 + self::bump_daily( $pending['hit'], $pending['miss'], $pending['excluded'] );
391 486 }
392 487
393 488 /**
394 489 * Fold the just-flushed counts into the persistent daily series. The
@@ -395,10 +490,10 @@
395 490 * hourly buckets expire after ~25h; this option is what makes 7/30-day
396 491 * hit-ratio trends possible (issue #44). Autoload off — it's only read
397 492 * by the dashboard/REST, never on the frontend hot path.
398 493 */
399 - private static function bump_daily( int $hits, int $misses ): void {
400 - if ( $hits <= 0 && $misses <= 0 ) {
494 + private static function bump_daily( int $hits, int $misses, int $excluded = 0 ): void {
495 + if ( $hits <= 0 && $misses <= 0 && $excluded <= 0 ) {
401 496 return;
402 497 }
403 498 $day = gmdate( 'Y-m-d' );
404 499 $series = get_option( self::DAILY_OPT, array() );
@@ -406,14 +501,16 @@
406 501 $series = array();
407 502 }
408 503 if ( ! isset( $series[ $day ] ) || ! is_array( $series[ $day ] ) ) {
409 504 $series[ $day ] = array(
410 - 'hits' => 0,
411 - 'misses' => 0,
505 + 'hits' => 0,
506 + 'misses' => 0,
507 + 'excluded' => 0,
412 508 );
413 509 }
414 - $series[ $day ]['hits'] += $hits;
415 - $series[ $day ]['misses'] += $misses;
510 + $series[ $day ]['hits'] += $hits;
511 + $series[ $day ]['misses'] += $misses;
512 + $series[ $day ]['excluded'] = (int) ( $series[ $day ]['excluded'] ?? 0 ) + $excluded;
416 513 if ( count( $series ) > self::DAILY_MAX_DAYS ) {
417 514 ksort( $series );
418 515 $series = array_slice( $series, -self::DAILY_MAX_DAYS, null, true );
419 516 }
@@ -436,16 +533,18 @@
436 533 foreach ( $series as $date => $row ) {
437 534 if ( ! is_array( $row ) ) {
438 535 continue;
439 536 }
440 - $hits = (int) ( $row['hits'] ?? 0 );
441 - $misses = (int) ( $row['misses'] ?? 0 );
442 - $total = $hits + $misses;
443 - $out[] = array(
444 - 'date' => (string) $date,
445 - 'hits' => $hits,
446 - 'misses' => $misses,
447 - 'ratio' => $total > 0 ? round( $hits / $total, 4 ) : 0.0,
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,
448 547 );
449 548 }
450 549 return $out;
451 550 }