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 +166 -26 1.0.7 → 1.3.6 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),
@@ -189,8 +232,15 @@
189 232 // .htaccess can't header/log a static serve — see
190 233 // Cache::static_rewrite_allowed(). So Apache is the lone server that
191 234 // serves static hits below PHP yet logs them to the SERVER's access
192 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.
193 243 if ( Server::APACHE !== Server::type() ) {
194 244 return 0;
195 245 }
196 246
@@ -268,9 +318,9 @@
268 318 private static function read_buffer() {
269 319 // Drop the cached 'options' entry for our key so get_option() falls
270 320 // through to the DB. Harmless on a persistent cache (it just reloads
271 321 // from the DB once); essential on a non-persistent one.
272 - wp_cache_delete( self::OPT_KEY, 'options' );
322 + \wp_cache_delete( self::OPT_KEY, 'options' );
273 323 return get_option( self::OPT_KEY, array() );
274 324 }
275 325
276 326 private static function write_buffer( array $buf ): void {
@@ -292,11 +342,13 @@
292 342 $out = array();
293 343 foreach ( $buf as $b ) {
294 344 if ( is_array( $b ) && isset( $b['ts'], $b['hits'], $b['misses'] ) ) {
295 345 $out[] = array(
296 - 'ts' => (int) $b['ts'],
297 - 'hits' => (int) $b['hits'],
298 - '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 ),
299 351 );
300 352 }
301 353 }
302 354 return $out;
@@ -302,32 +354,48 @@
302 354 return $out;
303 355 }
304 356
305 357 /**
306 - * 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)
307 362 *
308 - * @return array{hits:int,misses:int,ratio:float}
363 + * @return array{hits:int,misses:int,excluded:int,ratio:float}
309 364 */
310 365 public static function totals_24h(): array {
311 - $buckets = self::buckets();
312 - $hits = 0;
313 - $misses = 0;
366 + $buckets = self::buckets();
367 + $hits = 0;
368 + $misses = 0;
369 + $excluded = 0;
314 370 foreach ( $buckets as $b ) {
315 - $hits += $b['hits'];
316 - $misses += $b['misses'];
371 + $hits += $b['hits'];
372 + $misses += $b['misses'];
373 + $excluded += $b['excluded'];
317 374 }
318 375 $total = $hits + $misses;
319 376 return array(
320 - 'hits' => $hits,
321 - 'misses' => $misses,
322 - '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,
323 381 );
324 382 }
325 383
326 384 public static function reset(): void {
327 385 delete_transient( self::TRANSIENT_KEY );
386 + // The bucket buffer lives in the OPT_KEY option (migrated off the
387 + // transient); reset() must clear it too, or record→reset leaves the
388 + // old hit/miss buckets behind and buckets() still reports them.
389 + delete_option( self::OPT_KEY );
390 + \wp_cache_delete( self::OPT_KEY, 'options' );
328 391 delete_option( self::SERVER_LOG_OFFSET_OPT );
329 - self::$pending = array( 'hit' => 0, 'miss' => 0 );
392 + delete_option( self::DAILY_OPT );
393 + self::$pending = array(
394 + 'hit' => 0,
395 + 'miss' => 0,
396 + 'excluded' => 0,
397 + );
330 398 }
331 399
332 400 /**
333 401 * One-shot register on first record_* call this request.
@@ -346,12 +414,16 @@
346 414 * MAX_BUCKETS.
347 415 */
348 416 public static function flush_pending(): void {
349 417 $pending = self::$pending;
350 - if ( 0 === $pending['hit'] && 0 === $pending['miss'] ) {
418 + if ( 0 === $pending['hit'] && 0 === $pending['miss'] && 0 === $pending['excluded'] ) {
351 419 return;
352 420 }
353 - self::$pending = array( 'hit' => 0, 'miss' => 0 );
421 + self::$pending = array(
422 + 'hit' => 0,
423 + 'miss' => 0,
424 + 'excluded' => 0,
425 + );
354 426
355 427 $hour = (int) ( time() - ( time() % 3600 ) );
356 428 $buf = self::buckets();
357 429 $last = end( $buf );
@@ -357,18 +429,21 @@
357 429 $last = end( $buf );
358 430 $updated = false;
359 431
360 432 if ( $last && $last['ts'] === $hour ) {
361 - $buf[ count( $buf ) - 1 ]['hits'] += $pending['hit'];
362 - $buf[ count( $buf ) - 1 ]['misses'] += $pending['miss'];
363 - $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;
364 438 }
365 439
366 440 if ( ! $updated ) {
367 441 $buf[] = array(
368 - 'ts' => $hour,
369 - 'hits' => $pending['hit'],
370 - 'misses' => $pending['miss'],
442 + 'ts' => $hour,
443 + 'hits' => $pending['hit'],
444 + 'misses' => $pending['miss'],
445 + 'excluded' => $pending['excluded'],
371 446 );
372 447 while ( count( $buf ) > self::MAX_BUCKETS ) {
373 448 array_shift( $buf );
374 449 }
@@ -374,6 +449,71 @@
374 449 }
375 450 }
376 451
377 452 self::write_buffer( $buf );
453 + self::bump_daily( $pending['hit'], $pending['miss'], $pending['excluded'] );
454 + }
455 +
456 + /**
457 + * Fold the just-flushed counts into the persistent daily series. The
458 + * hourly buckets expire after ~25h; this option is what makes 7/30-day
459 + * hit-ratio trends possible (issue #44). Autoload off — it's only read
460 + * by the dashboard/REST, never on the frontend hot path.
461 + */
462 + private static function bump_daily( int $hits, int $misses, int $excluded = 0 ): void {
463 + if ( $hits <= 0 && $misses <= 0 && $excluded <= 0 ) {
464 + return;
465 + }
466 + $day = gmdate( 'Y-m-d' );
467 + $series = get_option( self::DAILY_OPT, array() );
468 + if ( ! is_array( $series ) ) {
469 + $series = array();
470 + }
471 + if ( ! isset( $series[ $day ] ) || ! is_array( $series[ $day ] ) ) {
472 + $series[ $day ] = array(
473 + 'hits' => 0,
474 + 'misses' => 0,
475 + 'excluded' => 0,
476 + );
477 + }
478 + $series[ $day ]['hits'] += $hits;
479 + $series[ $day ]['misses'] += $misses;
480 + $series[ $day ]['excluded'] = (int) ( $series[ $day ]['excluded'] ?? 0 ) + $excluded;
481 + if ( count( $series ) > self::DAILY_MAX_DAYS ) {
482 + ksort( $series );
483 + $series = array_slice( $series, -self::DAILY_MAX_DAYS, null, true );
484 + }
485 + update_option( self::DAILY_OPT, $series, false );
486 + }
487 +
488 + /**
489 + * The stored daily hit/miss series, oldest→newest, at most $days rows.
490 + *
491 + * @return array<int,array{date:string,hits:int,misses:int,ratio:float}>
492 + */
493 + public static function daily_series( int $days = 30 ): array {
494 + $series = get_option( self::DAILY_OPT, array() );
495 + if ( ! is_array( $series ) || empty( $series ) ) {
496 + return array();
497 + }
498 + ksort( $series );
499 + $series = array_slice( $series, -max( 1, $days ), null, true );
500 + $out = array();
501 + foreach ( $series as $date => $row ) {
502 + if ( ! is_array( $row ) ) {
503 + continue;
504 + }
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,
515 + );
516 + }
517 + return $out;
378 518 }
379 519 }