PluginProbe
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN / 1.3.4
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN v1.3.4
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 1.1.6 1.1.7 All 30 releases
← All changes | includes/class-hit-counter.php +100 -40 1.1.31.3.4 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),
@@ -298,11 +335,13 @@
298 335 $out = array();
299 336 foreach ( $buf as $b ) {
300 337 if ( is_array( $b ) && isset( $b['ts'], $b['hits'], $b['misses'] ) ) {
301 338 $out[] = array(
302 - 'ts' => (int) $b['ts'],
303 - 'hits' => (int) $b['hits'],
304 - 'misses' => (int) $b['misses'],
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 ),
305 344 );
306 345 }
307 346 }
308 347 return $out;
@@ -308,25 +347,31 @@
308 347 return $out;
309 348 }
310 349
311 350 /**
312 - * Totals over the last 24h (sum across all buckets).
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)
313 355 *
314 - * @return array{hits:int,misses:int,ratio:float}
356 + * @return array{hits:int,misses:int,excluded:int,ratio:float}
315 357 */
316 358 public static function totals_24h(): array {
317 - $buckets = self::buckets();
318 - $hits = 0;
319 - $misses = 0;
359 + $buckets = self::buckets();
360 + $hits = 0;
361 + $misses = 0;
362 + $excluded = 0;
320 363 foreach ( $buckets as $b ) {
321 - $hits += $b['hits'];
322 - $misses += $b['misses'];
364 + $hits += $b['hits'];
365 + $misses += $b['misses'];
366 + $excluded += $b['excluded'];
323 367 }
324 368 $total = $hits + $misses;
325 369 return array(
326 - 'hits' => $hits,
327 - 'misses' => $misses,
328 - 'ratio' => $total > 0 ? round( $hits / $total, 4 ) : 0.0,
370 + 'hits' => $hits,
371 + 'misses' => $misses,
372 + 'excluded' => $excluded,
373 + 'ratio' => $total > 0 ? round( $hits / $total, 4 ) : 0.0,
329 374 );
330 375 }
331 376
332 377 public static function reset(): void {
@@ -337,9 +382,13 @@
337 382 delete_option( self::OPT_KEY );
338 383 \wp_cache_delete( self::OPT_KEY, 'options' );
339 384 delete_option( self::SERVER_LOG_OFFSET_OPT );
340 385 delete_option( self::DAILY_OPT );
341 - self::$pending = array( 'hit' => 0, 'miss' => 0 );
386 + self::$pending = array(
387 + 'hit' => 0,
388 + 'miss' => 0,
389 + 'excluded' => 0,
390 + );
342 391 }
343 392
344 393 /**
345 394 * One-shot register on first record_* call this request.
@@ -358,12 +407,16 @@
358 407 * MAX_BUCKETS.
359 408 */
360 409 public static function flush_pending(): void {
361 410 $pending = self::$pending;
362 - if ( 0 === $pending['hit'] && 0 === $pending['miss'] ) {
411 + if ( 0 === $pending['hit'] && 0 === $pending['miss'] && 0 === $pending['excluded'] ) {
363 412 return;
364 413 }
365 - self::$pending = array( 'hit' => 0, 'miss' => 0 );
414 + self::$pending = array(
415 + 'hit' => 0,
416 + 'miss' => 0,
417 + 'excluded' => 0,
418 + );
366 419
367 420 $hour = (int) ( time() - ( time() % 3600 ) );
368 421 $buf = self::buckets();
369 422 $last = end( $buf );
@@ -369,18 +422,21 @@
369 422 $last = end( $buf );
370 423 $updated = false;
371 424
372 425 if ( $last && $last['ts'] === $hour ) {
373 - $buf[ count( $buf ) - 1 ]['hits'] += $pending['hit'];
374 - $buf[ count( $buf ) - 1 ]['misses'] += $pending['miss'];
375 - $updated = true;
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;
376 431 }
377 432
378 433 if ( ! $updated ) {
379 434 $buf[] = array(
380 - 'ts' => $hour,
381 - 'hits' => $pending['hit'],
382 - 'misses' => $pending['miss'],
435 + 'ts' => $hour,
436 + 'hits' => $pending['hit'],
437 + 'misses' => $pending['miss'],
438 + 'excluded' => $pending['excluded'],
383 439 );
384 440 while ( count( $buf ) > self::MAX_BUCKETS ) {
385 441 array_shift( $buf );
386 442 }
@@ -386,9 +442,9 @@
386 442 }
387 443 }
388 444
389 445 self::write_buffer( $buf );
390 - self::bump_daily( $pending['hit'], $pending['miss'] );
446 + self::bump_daily( $pending['hit'], $pending['miss'], $pending['excluded'] );
391 447 }
392 448
393 449 /**
394 450 * Fold the just-flushed counts into the persistent daily series. The
@@ -395,10 +451,10 @@
395 451 * hourly buckets expire after ~25h; this option is what makes 7/30-day
396 452 * hit-ratio trends possible (issue #44). Autoload off — it's only read
397 453 * by the dashboard/REST, never on the frontend hot path.
398 454 */
399 - private static function bump_daily( int $hits, int $misses ): void {
400 - if ( $hits <= 0 && $misses <= 0 ) {
455 + private static function bump_daily( int $hits, int $misses, int $excluded = 0 ): void {
456 + if ( $hits <= 0 && $misses <= 0 && $excluded <= 0 ) {
401 457 return;
402 458 }
403 459 $day = gmdate( 'Y-m-d' );
404 460 $series = get_option( self::DAILY_OPT, array() );
@@ -406,14 +462,16 @@
406 462 $series = array();
407 463 }
408 464 if ( ! isset( $series[ $day ] ) || ! is_array( $series[ $day ] ) ) {
409 465 $series[ $day ] = array(
410 - 'hits' => 0,
411 - 'misses' => 0,
466 + 'hits' => 0,
467 + 'misses' => 0,
468 + 'excluded' => 0,
412 469 );
413 470 }
414 - $series[ $day ]['hits'] += $hits;
415 - $series[ $day ]['misses'] += $misses;
471 + $series[ $day ]['hits'] += $hits;
472 + $series[ $day ]['misses'] += $misses;
473 + $series[ $day ]['excluded'] = (int) ( $series[ $day ]['excluded'] ?? 0 ) + $excluded;
416 474 if ( count( $series ) > self::DAILY_MAX_DAYS ) {
417 475 ksort( $series );
418 476 $series = array_slice( $series, -self::DAILY_MAX_DAYS, null, true );
419 477 }
@@ -436,16 +494,18 @@
436 494 foreach ( $series as $date => $row ) {
437 495 if ( ! is_array( $row ) ) {
438 496 continue;
439 497 }
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,
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,
448 508 );
449 509 }
450 510 return $out;
451 511 }