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 +159 -26 1.0.81.3.4 View file →
@@ -46,13 +46,26 @@
46 46 * in-request copy from a non-persistent cache can't shadow the DB value.
47 47 */
48 48 public const OPT_KEY = 'xspeed_hit_buffer';
49 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 +
50 56 /**
51 - * @var array<int,int> Pending increments keyed by metric ('hit'|'miss').
52 - * 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).
53 62 */
54 - private static $pending = array( 'hit' => 0, 'miss' => 0 );
63 + private static $pending = array(
64 + 'hit' => 0,
65 + 'miss' => 0,
66 + 'excluded' => 0,
67 + );
55 68
56 69 /**
57 70 * @var bool Whether the shutdown flush is already registered.
58 71 */
@@ -62,8 +75,38 @@
62 75 ++self::$pending['hit'];
63 76 self::ensure_shutdown_flush();
64 77 }
65 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 +
66 109 public static function record_miss(): void {
67 110 ++self::$pending['miss'];
68 111 // Flush misses INLINE, not at shutdown. A MISS is recorded ONLY here
69 112 // (HITs additionally have the durable hits.log drain as a backstop),
@@ -268,9 +311,9 @@
268 311 private static function read_buffer() {
269 312 // Drop the cached 'options' entry for our key so get_option() falls
270 313 // through to the DB. Harmless on a persistent cache (it just reloads
271 314 // from the DB once); essential on a non-persistent one.
272 - wp_cache_delete( self::OPT_KEY, 'options' );
315 + \wp_cache_delete( self::OPT_KEY, 'options' );
273 316 return get_option( self::OPT_KEY, array() );
274 317 }
275 318
276 319 private static function write_buffer( array $buf ): void {
@@ -292,11 +335,13 @@
292 335 $out = array();
293 336 foreach ( $buf as $b ) {
294 337 if ( is_array( $b ) && isset( $b['ts'], $b['hits'], $b['misses'] ) ) {
295 338 $out[] = array(
296 - 'ts' => (int) $b['ts'],
297 - 'hits' => (int) $b['hits'],
298 - '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 ),
299 344 );
300 345 }
301 346 }
302 347 return $out;
@@ -302,32 +347,48 @@
302 347 return $out;
303 348 }
304 349
305 350 /**
306 - * 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)
307 355 *
308 - * @return array{hits:int,misses:int,ratio:float}
356 + * @return array{hits:int,misses:int,excluded:int,ratio:float}
309 357 */
310 358 public static function totals_24h(): array {
311 - $buckets = self::buckets();
312 - $hits = 0;
313 - $misses = 0;
359 + $buckets = self::buckets();
360 + $hits = 0;
361 + $misses = 0;
362 + $excluded = 0;
314 363 foreach ( $buckets as $b ) {
315 - $hits += $b['hits'];
316 - $misses += $b['misses'];
364 + $hits += $b['hits'];
365 + $misses += $b['misses'];
366 + $excluded += $b['excluded'];
317 367 }
318 368 $total = $hits + $misses;
319 369 return array(
320 - 'hits' => $hits,
321 - 'misses' => $misses,
322 - '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,
323 374 );
324 375 }
325 376
326 377 public static function reset(): void {
327 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' );
328 384 delete_option( self::SERVER_LOG_OFFSET_OPT );
329 - self::$pending = array( 'hit' => 0, 'miss' => 0 );
385 + delete_option( self::DAILY_OPT );
386 + self::$pending = array(
387 + 'hit' => 0,
388 + 'miss' => 0,
389 + 'excluded' => 0,
390 + );
330 391 }
331 392
332 393 /**
333 394 * One-shot register on first record_* call this request.
@@ -346,12 +407,16 @@
346 407 * MAX_BUCKETS.
347 408 */
348 409 public static function flush_pending(): void {
349 410 $pending = self::$pending;
350 - if ( 0 === $pending['hit'] && 0 === $pending['miss'] ) {
411 + if ( 0 === $pending['hit'] && 0 === $pending['miss'] && 0 === $pending['excluded'] ) {
351 412 return;
352 413 }
353 - self::$pending = array( 'hit' => 0, 'miss' => 0 );
414 + self::$pending = array(
415 + 'hit' => 0,
416 + 'miss' => 0,
417 + 'excluded' => 0,
418 + );
354 419
355 420 $hour = (int) ( time() - ( time() % 3600 ) );
356 421 $buf = self::buckets();
357 422 $last = end( $buf );
@@ -357,18 +422,21 @@
357 422 $last = end( $buf );
358 423 $updated = false;
359 424
360 425 if ( $last && $last['ts'] === $hour ) {
361 - $buf[ count( $buf ) - 1 ]['hits'] += $pending['hit'];
362 - $buf[ count( $buf ) - 1 ]['misses'] += $pending['miss'];
363 - $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;
364 431 }
365 432
366 433 if ( ! $updated ) {
367 434 $buf[] = array(
368 - 'ts' => $hour,
369 - 'hits' => $pending['hit'],
370 - 'misses' => $pending['miss'],
435 + 'ts' => $hour,
436 + 'hits' => $pending['hit'],
437 + 'misses' => $pending['miss'],
438 + 'excluded' => $pending['excluded'],
371 439 );
372 440 while ( count( $buf ) > self::MAX_BUCKETS ) {
373 441 array_shift( $buf );
374 442 }
@@ -374,6 +442,71 @@
374 442 }
375 443 }
376 444
377 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;
378 511 }
379 512 }