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 +387 -27 1.0.21.3.4 View file →
@@ -29,14 +29,45 @@
29 29 public const TTL = 90000; // 25h
30 30 public const MAX_BUCKETS = 24;
31 31
32 32 /**
33 - * @var array<int,int> Pending increments keyed by metric ('hit'|'miss').
34 - * Flushed to the transient on shutdown.
33 + * Option key holding the bucket buffer.
34 + *
35 + * Why an OPTION, not a transient (fixed 2026-06-16): with a persistent
36 + * object cache absent or misconfigured, `set_transient()` writes to the
37 + * object cache ONLY (never the DB) when an external object cache is
38 + * "in use" — even if that cache is non-persistent (e.g. xSpeed's own
39 + * object-cache drop-in falling back to an in-request array because Redis
40 + * isn't reachable). In that state every recorded hit/miss was written to
41 + * a per-request cache and discarded at request end, so the dashboard
42 + * hit-ratio read 0 (or a meaningless 100% off one drained log line).
43 + * Options always persist to wp_options, so the counter survives across
44 + * requests regardless of the object-cache backend. read_buffer() also
45 + * busts the options-group cache entry before reading so a stale
46 + * in-request copy from a non-persistent cache can't shadow the DB value.
35 47 */
36 - private static $pending = array( 'hit' => 0, 'miss' => 0 );
48 + public const OPT_KEY = 'xspeed_hit_buffer';
37 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 +
38 56 /**
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).
62 + */
63 + private static $pending = array(
64 + 'hit' => 0,
65 + 'miss' => 0,
66 + 'excluded' => 0,
67 + );
68 +
69 + /**
39 70 * @var bool Whether the shutdown flush is already registered.
40 71 */
41 72 private static $shutdown_registered = false;
42 73
@@ -44,14 +75,227 @@
44 75 ++self::$pending['hit'];
45 76 self::ensure_shutdown_flush();
46 77 }
47 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 +
48 109 public static function record_miss(): void {
49 110 ++self::$pending['miss'];
111 + // Flush misses INLINE, not at shutdown. A MISS is recorded ONLY here
112 + // (HITs additionally have the durable hits.log drain as a backstop),
113 + // so if a miss flush is ever dropped the dashboard ratio skews toward
114 + // 100%. Flushing inline guarantees the miss is committed to the
115 + // options-backed buffer (see OPT_KEY) within this request, before any
116 + // shutdown-time object-cache teardown could interfere. Misses are
117 + // low-frequency (one per page per cache fill), so the inline write
118 + // cost is negligible; HITs stay deferred (high-volume).
119 + self::flush_pending();
120 + }
121 +
122 + /**
123 + * Add `$count` HITs in one shot. Used by collect_nginx_log_hits()
124 + * to attribute many HITs served directly by nginx (bypassing PHP)
125 + * to the counter once we've drained the log file.
126 + */
127 + public static function record_hits_batch( int $count ): void {
128 + if ( $count <= 0 ) {
129 + return;
130 + }
131 + self::$pending['hit'] += $count;
50 132 self::ensure_shutdown_flush();
51 133 }
52 134
53 135 /**
136 + * Drain the HITs log file at wp-content/cache/xspeed/hits.log. Two
137 + * serve paths that can't call record_hit() inline append one line per
138 + * HIT here: the nginx server-level rewrite block (see
139 + * Cache::nginx_snippet(), serves without ever reaching PHP) and the
140 + * advanced-cache.php drop-in (runs before WordPress loads, so
141 + * Hit_Counter isn't available). This method reads the line count,
142 + * truncates the file, and folds the count into Hit_Counter via
143 + * record_hits_batch — so both uncountable-inline paths still show up
144 + * in the dashboard hit-ratio on the next load.
145 + *
146 + * Returns the number of HITs collected (0 if the log is missing,
147 + * empty, or the rewrite block isn't engaged).
148 + *
149 + * Concurrency: file is opened with LOCK_EX before the read/truncate
150 + * round-trip so a concurrent nginx write can't lose entries. Nginx
151 + * uses buffer=16k flush=10s on its access_log so writes are batched
152 + * and the lock contention is negligible.
153 + */
154 + public static function collect_nginx_log_hits(): int {
155 + // Lives under uploads/, not the cache dir — see Cache::hits_log_dir()
156 + // (FBS-82478: a cache-dir access_log can take nginx down on purge/
157 + // uninstall).
158 + $path = Cache::hits_log_path();
159 + if ( ! file_exists( $path ) ) {
160 + return 0;
161 + }
162 + if ( filesize( $path ) === 0 ) {
163 + return 0;
164 + }
165 + // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen, WordPress.PHP.NoSilencedErrors.Discouraged -- WP_Filesystem doesn't model fopen+flock+ftruncate atomically; we need the lock to prevent nginx writes from being lost.
166 + $fp = @fopen( $path, 'r+' );
167 + if ( ! $fp ) {
168 + return 0;
169 + }
170 + // Non-blocking exclusive lock — if nginx is mid-write we just skip
171 + // this collection and try again on the next dashboard load.
172 + // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_flock -- See fopen rationale.
173 + if ( ! @flock( $fp, LOCK_EX | LOCK_NB ) ) { // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
174 + fclose( $fp ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- pairs with the flock'd fopen above; WP_Filesystem can't model flock.
175 + return 0;
176 + }
177 + $count = 0;
178 + while ( ( $line = fgets( $fp ) ) !== false ) {
179 + if ( '' !== rtrim( $line ) ) {
180 + ++$count;
181 + }
182 + }
183 + // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_ftruncate -- See fopen rationale.
184 + ftruncate( $fp, 0 );
185 + flock( $fp, LOCK_UN );
186 + fclose( $fp ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- pairs with the flock'd fopen above; WP_Filesystem can't model flock.
187 +
188 + if ( $count > 0 ) {
189 + self::record_hits_batch( $count );
190 + // Flush immediately — the next read of totals_24h() happens
191 + // inline in Cache::get_stats(), before register_shutdown_function
192 + // could fire. Without this, the dashboard sees stale numbers
193 + // and the just-drained HITs appear on the FOLLOWING refresh.
194 + self::flush_pending();
195 + }
196 + return $count;
197 + }
198 +
199 + /** Option key storing the last-scanned byte offset of the access log. */
200 + public const SERVER_LOG_OFFSET_OPT = 'xspeed_access_log_offset';
201 +
202 + /**
203 + * Count Apache/LiteSpeed static-rewrite HITs by scanning the web
204 + * server's access log.
205 + *
206 + * On Apache/LiteSpeed a cache HIT is served straight from the
207 + * `xspeed-static/` tree by a `.htaccess` RewriteRule — the request
208 + * never reaches PHP, so (unlike the nginx path, which logs to our own
209 + * dedicated hits.log) there's no inline hook to call record_hit().
210 + * Instead we read the server's own access log incrementally: every
211 + * request whose logged path contains our static-cache dir was a HIT
212 + * served below PHP.
213 + *
214 + * Incremental + safe:
215 + * - We remember a byte offset (SERVER_LOG_OFFSET_OPT) and only read
216 + * bytes appended since last time — O(new traffic), not O(log size).
217 + * - If the log shrank (rotation/truncation) we reset the offset to 0
218 + * and rescan from the top once, so a rotation never double-counts
219 + * or permanently desyncs.
220 + * - We never write to the log, only read; failure is silent.
221 + *
222 + * Returns 0 (and is a no-op) when no readable access log exists — the
223 + * common managed-host case. The drop-in/PHP path still counts its own
224 + * HITs, so hit-ratio degrades to "PHP-served hits only" rather than 0.
225 + *
226 + * @return int HITs folded in this call.
227 + */
228 + public static function collect_server_log_hits(): int {
229 + // Apache only. nginx writes its own dedicated hits.log (drained by
230 + // collect_nginx_log_hits); LiteSpeed routes hits through the PHP
231 + // drop-in (which also appends to that hits.log) because its
232 + // .htaccess can't header/log a static serve — see
233 + // Cache::static_rewrite_allowed(). So Apache is the lone server that
234 + // serves static hits below PHP yet logs them to the SERVER's access
235 + // log, which is what we scan here.
236 + if ( Server::APACHE !== Server::type() ) {
237 + return 0;
238 + }
239 +
240 + $path = Server::access_log_path();
241 + if ( '' === $path ) {
242 + return 0;
243 + }
244 +
245 + $size = @filesize( $path ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- log may vanish on rotation between checks.
246 + if ( false === $size ) {
247 + return 0;
248 + }
249 +
250 + $offset = (int) get_option( self::SERVER_LOG_OFFSET_OPT, 0 );
251 + if ( $offset > $size ) {
252 + // Log was rotated/truncated since last scan — start over so we
253 + // don't seek past EOF and miss the new file's lines.
254 + $offset = 0;
255 + }
256 + if ( $offset === $size ) {
257 + return 0; // Nothing new since last drain.
258 + }
259 +
260 + // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen, WordPress.PHP.NoSilencedErrors.Discouraged -- read-only incremental tail of an external log; WP_Filesystem can't fseek and would buffer the whole file through memory.
261 + $fp = @fopen( $path, 'r' );
262 + if ( ! $fp ) {
263 + return 0;
264 + }
265 + if ( $offset > 0 ) {
266 + fseek( $fp, $offset );
267 + }
268 +
269 + // The static-cache dir, as it appears in a logged request path. We
270 + // match on the request-target substring so the access-log format
271 + // (combined/common/custom) doesn't matter — every format includes
272 + // the request line.
273 + $needle = '/' . trim( str_replace( ABSPATH, '', XSPEED_CACHE_STATIC_DIR ), '/' );
274 + $count = 0;
275 + while ( ( $line = fgets( $fp ) ) !== false ) {
276 + // Only count GET requests that landed on the static tree. The
277 + // "GET " + needle pairing avoids counting our own loopback
278 + // probe writes or unrelated dir listings.
279 + if ( false !== strpos( $line, $needle ) && false !== strpos( $line, 'GET ' ) ) {
280 + ++$count;
281 + }
282 + }
283 + $new_offset = ftell( $fp );
284 + fclose( $fp ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- pairs with the read-only fopen above.
285 +
286 + // Persist the offset even when count is 0 so we don't re-scan the
287 + // same non-matching bytes every dashboard load.
288 + update_option( self::SERVER_LOG_OFFSET_OPT, (int) $new_offset, false );
289 +
290 + if ( $count > 0 ) {
291 + self::record_hits_batch( $count );
292 + self::flush_pending();
293 + }
294 + return $count;
295 + }
296 +
297 + /**
54 298 * Returns up to MAX_BUCKETS most-recent hourly buckets oldest →
55 299 * newest. Each bucket: [ts => unix hour-start, hits => int, misses
56 300 * => int ].
57 301 *
@@ -56,10 +300,35 @@
56 300 * => int ].
57 301 *
58 302 * @return array<int,array{ts:int,hits:int,misses:int}>
59 303 */
304 + /**
305 + * Read the bucket buffer straight from the options table, busting any
306 + * stale per-request object-cache copy first so a non-persistent cache
307 + * can never shadow the committed DB value. See OPT_KEY docblock.
308 + *
309 + * @return mixed Raw stored value (array on success).
310 + */
311 + private static function read_buffer() {
312 + // Drop the cached 'options' entry for our key so get_option() falls
313 + // through to the DB. Harmless on a persistent cache (it just reloads
314 + // from the DB once); essential on a non-persistent one.
315 + \wp_cache_delete( self::OPT_KEY, 'options' );
316 + return get_option( self::OPT_KEY, array() );
317 + }
318 +
319 + private static function write_buffer( array $buf ): void {
320 + // Autoload 'no' — the buffer is read only in admin/stats contexts, so
321 + // it must never inflate the frontend alloptions payload.
322 + if ( false === get_option( self::OPT_KEY, false ) ) {
323 + add_option( self::OPT_KEY, $buf, '', 'no' );
324 + return;
325 + }
326 + update_option( self::OPT_KEY, $buf );
327 + }
328 +
60 329 public static function buckets(): array {
61 - $buf = get_transient( self::TRANSIENT_KEY );
330 + $buf = self::read_buffer();
62 331 if ( ! is_array( $buf ) ) {
63 332 return array();
64 333 }
65 334 // Defensive — strip anything not shaped right.
@@ -66,11 +335,13 @@
66 335 $out = array();
67 336 foreach ( $buf as $b ) {
68 337 if ( is_array( $b ) && isset( $b['ts'], $b['hits'], $b['misses'] ) ) {
69 338 $out[] = array(
70 - 'ts' => (int) $b['ts'],
71 - 'hits' => (int) $b['hits'],
72 - '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 ),
73 344 );
74 345 }
75 346 }
76 347 return $out;
@@ -76,31 +347,48 @@
76 347 return $out;
77 348 }
78 349
79 350 /**
80 - * 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)
81 355 *
82 - * @return array{hits:int,misses:int,ratio:float}
356 + * @return array{hits:int,misses:int,excluded:int,ratio:float}
83 357 */
84 358 public static function totals_24h(): array {
85 - $buckets = self::buckets();
86 - $hits = 0;
87 - $misses = 0;
359 + $buckets = self::buckets();
360 + $hits = 0;
361 + $misses = 0;
362 + $excluded = 0;
88 363 foreach ( $buckets as $b ) {
89 - $hits += $b['hits'];
90 - $misses += $b['misses'];
364 + $hits += $b['hits'];
365 + $misses += $b['misses'];
366 + $excluded += $b['excluded'];
91 367 }
92 368 $total = $hits + $misses;
93 369 return array(
94 - 'hits' => $hits,
95 - 'misses' => $misses,
96 - '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,
97 374 );
98 375 }
99 376
100 377 public static function reset(): void {
101 378 delete_transient( self::TRANSIENT_KEY );
102 - self::$pending = array( 'hit' => 0, 'miss' => 0 );
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' );
384 + delete_option( self::SERVER_LOG_OFFSET_OPT );
385 + delete_option( self::DAILY_OPT );
386 + self::$pending = array(
387 + 'hit' => 0,
388 + 'miss' => 0,
389 + 'excluded' => 0,
390 + );
103 391 }
104 392
105 393 /**
106 394 * One-shot register on first record_* call this request.
@@ -119,12 +407,16 @@
119 407 * MAX_BUCKETS.
120 408 */
121 409 public static function flush_pending(): void {
122 410 $pending = self::$pending;
123 - if ( 0 === $pending['hit'] && 0 === $pending['miss'] ) {
411 + if ( 0 === $pending['hit'] && 0 === $pending['miss'] && 0 === $pending['excluded'] ) {
124 412 return;
125 413 }
126 - self::$pending = array( 'hit' => 0, 'miss' => 0 );
414 + self::$pending = array(
415 + 'hit' => 0,
416 + 'miss' => 0,
417 + 'excluded' => 0,
418 + );
127 419
128 420 $hour = (int) ( time() - ( time() % 3600 ) );
129 421 $buf = self::buckets();
130 422 $last = end( $buf );
@@ -130,18 +422,21 @@
130 422 $last = end( $buf );
131 423 $updated = false;
132 424
133 425 if ( $last && $last['ts'] === $hour ) {
134 - $buf[ count( $buf ) - 1 ]['hits'] += $pending['hit'];
135 - $buf[ count( $buf ) - 1 ]['misses'] += $pending['miss'];
136 - $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;
137 431 }
138 432
139 433 if ( ! $updated ) {
140 434 $buf[] = array(
141 - 'ts' => $hour,
142 - 'hits' => $pending['hit'],
143 - 'misses' => $pending['miss'],
435 + 'ts' => $hour,
436 + 'hits' => $pending['hit'],
437 + 'misses' => $pending['miss'],
438 + 'excluded' => $pending['excluded'],
144 439 );
145 440 while ( count( $buf ) > self::MAX_BUCKETS ) {
146 441 array_shift( $buf );
147 442 }
@@ -146,7 +441,72 @@
146 441 array_shift( $buf );
147 442 }
148 443 }
149 444
150 - set_transient( self::TRANSIENT_KEY, $buf, self::TTL );
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;
151 511 }
152 512 }