PluginProbe
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN / 1.3.6
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN v1.3.6
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 1.1.5 All 32 releases
← All changes | includes/class-hit-counter.php +107 -40 1.1.2 → 1.3.6 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,38 @@
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 + * 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 +
72 109 public static function record_miss(): void {
73 110 ++self::$pending['miss'];
74 111 // Flush misses INLINE, not at shutdown. A MISS is recorded ONLY here
75 112 // (HITs additionally have the durable hits.log drain as a backstop),
@@ -195,8 +232,15 @@
195 232 // .htaccess can't header/log a static serve — see
196 233 // Cache::static_rewrite_allowed(). So Apache is the lone server that
197 234 // serves static hits below PHP yet logs them to the SERVER's access
198 235 // log, which is what we scan here.
236 + //
237 + // LiteSpeed stays out even with the Static Fast Path opt-in (#509):
238 + // its access log records the ORIGINAL request line ("GET / …"), not
239 + // the rewritten static-file path, so the needle below can never
240 + // match and scanning would only pretend to count. Verified on
241 + // OpenLiteSpeed 1.8. Those hits are genuinely uncounted, which the
242 + // dashboard discloses via stats.static_hits_uncounted.
199 243 if ( Server::APACHE !== Server::type() ) {
200 244 return 0;
201 245 }
202 246
@@ -298,11 +342,13 @@
298 342 $out = array();
299 343 foreach ( $buf as $b ) {
300 344 if ( is_array( $b ) && isset( $b['ts'], $b['hits'], $b['misses'] ) ) {
301 345 $out[] = array(
302 - 'ts' => (int) $b['ts'],
303 - 'hits' => (int) $b['hits'],
304 - 'misses' => (int) $b['misses'],
346 + 'ts' => (int) $b['ts'],
347 + 'hits' => (int) $b['hits'],
348 + 'misses' => (int) $b['misses'],
349 + // Older buckets (pre-#118) have no 'excluded' key — default 0.
350 + 'excluded' => (int) ( $b['excluded'] ?? 0 ),
305 351 );
306 352 }
307 353 }
308 354 return $out;
@@ -308,25 +354,31 @@
308 354 return $out;
309 355 }
310 356
311 357 /**
312 - * Totals over the last 24h (sum across all buckets).
358 + * Totals over the last 24h (sum across all buckets). `ratio` is computed
359 + * over hits + real misses only; `excluded` (404s + bots) is reported
360 + * alongside but kept OUT of the denominator so a scanner flood can't crater
361 + * the number. (#118)
313 362 *
314 - * @return array{hits:int,misses:int,ratio:float}
363 + * @return array{hits:int,misses:int,excluded:int,ratio:float}
315 364 */
316 365 public static function totals_24h(): array {
317 - $buckets = self::buckets();
318 - $hits = 0;
319 - $misses = 0;
366 + $buckets = self::buckets();
367 + $hits = 0;
368 + $misses = 0;
369 + $excluded = 0;
320 370 foreach ( $buckets as $b ) {
321 - $hits += $b['hits'];
322 - $misses += $b['misses'];
371 + $hits += $b['hits'];
372 + $misses += $b['misses'];
373 + $excluded += $b['excluded'];
323 374 }
324 375 $total = $hits + $misses;
325 376 return array(
326 - 'hits' => $hits,
327 - 'misses' => $misses,
328 - 'ratio' => $total > 0 ? round( $hits / $total, 4 ) : 0.0,
377 + 'hits' => $hits,
378 + 'misses' => $misses,
379 + 'excluded' => $excluded,
380 + 'ratio' => $total > 0 ? round( $hits / $total, 4 ) : 0.0,
329 381 );
330 382 }
331 383
332 384 public static function reset(): void {
@@ -337,9 +389,13 @@
337 389 delete_option( self::OPT_KEY );
338 390 \wp_cache_delete( self::OPT_KEY, 'options' );
339 391 delete_option( self::SERVER_LOG_OFFSET_OPT );
340 392 delete_option( self::DAILY_OPT );
341 - self::$pending = array( 'hit' => 0, 'miss' => 0 );
393 + self::$pending = array(
394 + 'hit' => 0,
395 + 'miss' => 0,
396 + 'excluded' => 0,
397 + );
342 398 }
343 399
344 400 /**
345 401 * One-shot register on first record_* call this request.
@@ -358,12 +414,16 @@
358 414 * MAX_BUCKETS.
359 415 */
360 416 public static function flush_pending(): void {
361 417 $pending = self::$pending;
362 - if ( 0 === $pending['hit'] && 0 === $pending['miss'] ) {
418 + if ( 0 === $pending['hit'] && 0 === $pending['miss'] && 0 === $pending['excluded'] ) {
363 419 return;
364 420 }
365 - self::$pending = array( 'hit' => 0, 'miss' => 0 );
421 + self::$pending = array(
422 + 'hit' => 0,
423 + 'miss' => 0,
424 + 'excluded' => 0,
425 + );
366 426
367 427 $hour = (int) ( time() - ( time() % 3600 ) );
368 428 $buf = self::buckets();
369 429 $last = end( $buf );
@@ -369,18 +429,21 @@
369 429 $last = end( $buf );
370 430 $updated = false;
371 431
372 432 if ( $last && $last['ts'] === $hour ) {
373 - $buf[ count( $buf ) - 1 ]['hits'] += $pending['hit'];
374 - $buf[ count( $buf ) - 1 ]['misses'] += $pending['miss'];
375 - $updated = true;
433 + $i = count( $buf ) - 1;
434 + $buf[ $i ]['hits'] += $pending['hit'];
435 + $buf[ $i ]['misses'] += $pending['miss'];
436 + $buf[ $i ]['excluded'] += $pending['excluded'];
437 + $updated = true;
376 438 }
377 439
378 440 if ( ! $updated ) {
379 441 $buf[] = array(
380 - 'ts' => $hour,
381 - 'hits' => $pending['hit'],
382 - 'misses' => $pending['miss'],
442 + 'ts' => $hour,
443 + 'hits' => $pending['hit'],
444 + 'misses' => $pending['miss'],
445 + 'excluded' => $pending['excluded'],
383 446 );
384 447 while ( count( $buf ) > self::MAX_BUCKETS ) {
385 448 array_shift( $buf );
386 449 }
@@ -386,9 +449,9 @@
386 449 }
387 450 }
388 451
389 452 self::write_buffer( $buf );
390 - self::bump_daily( $pending['hit'], $pending['miss'] );
453 + self::bump_daily( $pending['hit'], $pending['miss'], $pending['excluded'] );
391 454 }
392 455
393 456 /**
394 457 * Fold the just-flushed counts into the persistent daily series. The
@@ -395,10 +458,10 @@
395 458 * hourly buckets expire after ~25h; this option is what makes 7/30-day
396 459 * hit-ratio trends possible (issue #44). Autoload off — it's only read
397 460 * by the dashboard/REST, never on the frontend hot path.
398 461 */
399 - private static function bump_daily( int $hits, int $misses ): void {
400 - if ( $hits <= 0 && $misses <= 0 ) {
462 + private static function bump_daily( int $hits, int $misses, int $excluded = 0 ): void {
463 + if ( $hits <= 0 && $misses <= 0 && $excluded <= 0 ) {
401 464 return;
402 465 }
403 466 $day = gmdate( 'Y-m-d' );
404 467 $series = get_option( self::DAILY_OPT, array() );
@@ -406,14 +469,16 @@
406 469 $series = array();
407 470 }
408 471 if ( ! isset( $series[ $day ] ) || ! is_array( $series[ $day ] ) ) {
409 472 $series[ $day ] = array(
410 - 'hits' => 0,
411 - 'misses' => 0,
473 + 'hits' => 0,
474 + 'misses' => 0,
475 + 'excluded' => 0,
412 476 );
413 477 }
414 - $series[ $day ]['hits'] += $hits;
415 - $series[ $day ]['misses'] += $misses;
478 + $series[ $day ]['hits'] += $hits;
479 + $series[ $day ]['misses'] += $misses;
480 + $series[ $day ]['excluded'] = (int) ( $series[ $day ]['excluded'] ?? 0 ) + $excluded;
416 481 if ( count( $series ) > self::DAILY_MAX_DAYS ) {
417 482 ksort( $series );
418 483 $series = array_slice( $series, -self::DAILY_MAX_DAYS, null, true );
419 484 }
@@ -436,16 +501,18 @@
436 501 foreach ( $series as $date => $row ) {
437 502 if ( ! is_array( $row ) ) {
438 503 continue;
439 504 }
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,
505 + $hits = (int) ( $row['hits'] ?? 0 );
506 + $misses = (int) ( $row['misses'] ?? 0 );
507 + $excluded = (int) ( $row['excluded'] ?? 0 );
508 + $total = $hits + $misses;
509 + $out[] = array(
510 + 'date' => (string) $date,
511 + 'hits' => $hits,
512 + 'misses' => $misses,
513 + 'excluded' => $excluded,
514 + 'ratio' => $total > 0 ? round( $hits / $total, 4 ) : 0.0,
448 515 );
449 516 }
450 517 return $out;
451 518 }