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 +326 -36 1.0.41.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,11 +75,49 @@
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'];
50 - self::ensure_shutdown_flush();
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();
51 120 }
52 121
53 122 /**
54 123 * Add `$count` HITs in one shot. Used by collect_nginx_log_hits()
@@ -63,13 +132,17 @@
63 132 self::ensure_shutdown_flush();
64 133 }
65 134
66 135 /**
67 - * Drain the nginx HITs log file written by the server-level rewrite
68 - * block (see Cache::nginx_snippet()). Each cache HIT served directly
69 - * by nginx appends one line to wp-content/cache/xspeed/hits.log;
70 - * this method reads the line count, truncates the file, and folds
71 - * the count into Hit_Counter via record_hits_batch.
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.
72 145 *
73 146 * Returns the number of HITs collected (0 if the log is missing,
74 147 * empty, or the rewrite block isn't engaged).
75 148 *
@@ -78,9 +151,12 @@
78 151 * uses buffer=16k flush=10s on its access_log so writes are batched
79 152 * and the lock contention is negligible.
80 153 */
81 154 public static function collect_nginx_log_hits(): int {
82 - $path = WP_CONTENT_DIR . '/cache/xspeed/hits.log';
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();
83 159 if ( ! file_exists( $path ) ) {
84 160 return 0;
85 161 }
86 162 if ( filesize( $path ) === 0 ) {
@@ -94,9 +170,9 @@
94 170 // Non-blocking exclusive lock — if nginx is mid-write we just skip
95 171 // this collection and try again on the next dashboard load.
96 172 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_flock -- See fopen rationale.
97 173 if ( ! @flock( $fp, LOCK_EX | LOCK_NB ) ) { // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
98 - fclose( $fp );
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.
99 175 return 0;
100 176 }
101 177 $count = 0;
102 178 while ( ( $line = fgets( $fp ) ) !== false ) {
@@ -106,9 +182,9 @@
106 182 }
107 183 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_ftruncate -- See fopen rationale.
108 184 ftruncate( $fp, 0 );
109 185 flock( $fp, LOCK_UN );
110 - fclose( $fp );
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.
111 187
112 188 if ( $count > 0 ) {
113 189 self::record_hits_batch( $count );
114 190 // Flush immediately — the next read of totals_24h() happens
@@ -119,9 +195,107 @@
119 195 }
120 196 return $count;
121 197 }
122 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 +
123 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 + /**
124 298 * Returns up to MAX_BUCKETS most-recent hourly buckets oldest →
125 299 * newest. Each bucket: [ts => unix hour-start, hits => int, misses
126 300 * => int ].
127 301 *
@@ -126,10 +300,35 @@
126 300 * => int ].
127 301 *
128 302 * @return array<int,array{ts:int,hits:int,misses:int}>
129 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 +
130 329 public static function buckets(): array {
131 - $buf = get_transient( self::TRANSIENT_KEY );
330 + $buf = self::read_buffer();
132 331 if ( ! is_array( $buf ) ) {
133 332 return array();
134 333 }
135 334 // Defensive — strip anything not shaped right.
@@ -136,11 +335,13 @@
136 335 $out = array();
137 336 foreach ( $buf as $b ) {
138 337 if ( is_array( $b ) && isset( $b['ts'], $b['hits'], $b['misses'] ) ) {
139 338 $out[] = array(
140 - 'ts' => (int) $b['ts'],
141 - 'hits' => (int) $b['hits'],
142 - '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 ),
143 344 );
144 345 }
145 346 }
146 347 return $out;
@@ -146,31 +347,48 @@
146 347 return $out;
147 348 }
148 349
149 350 /**
150 - * 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)
151 355 *
152 - * @return array{hits:int,misses:int,ratio:float}
356 + * @return array{hits:int,misses:int,excluded:int,ratio:float}
153 357 */
154 358 public static function totals_24h(): array {
155 - $buckets = self::buckets();
156 - $hits = 0;
157 - $misses = 0;
359 + $buckets = self::buckets();
360 + $hits = 0;
361 + $misses = 0;
362 + $excluded = 0;
158 363 foreach ( $buckets as $b ) {
159 - $hits += $b['hits'];
160 - $misses += $b['misses'];
364 + $hits += $b['hits'];
365 + $misses += $b['misses'];
366 + $excluded += $b['excluded'];
161 367 }
162 368 $total = $hits + $misses;
163 369 return array(
164 - 'hits' => $hits,
165 - 'misses' => $misses,
166 - '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,
167 374 );
168 375 }
169 376
170 377 public static function reset(): void {
171 378 delete_transient( self::TRANSIENT_KEY );
172 - 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 + );
173 391 }
174 392
175 393 /**
176 394 * One-shot register on first record_* call this request.
@@ -189,12 +407,16 @@
189 407 * MAX_BUCKETS.
190 408 */
191 409 public static function flush_pending(): void {
192 410 $pending = self::$pending;
193 - if ( 0 === $pending['hit'] && 0 === $pending['miss'] ) {
411 + if ( 0 === $pending['hit'] && 0 === $pending['miss'] && 0 === $pending['excluded'] ) {
194 412 return;
195 413 }
196 - self::$pending = array( 'hit' => 0, 'miss' => 0 );
414 + self::$pending = array(
415 + 'hit' => 0,
416 + 'miss' => 0,
417 + 'excluded' => 0,
418 + );
197 419
198 420 $hour = (int) ( time() - ( time() % 3600 ) );
199 421 $buf = self::buckets();
200 422 $last = end( $buf );
@@ -200,18 +422,21 @@
200 422 $last = end( $buf );
201 423 $updated = false;
202 424
203 425 if ( $last && $last['ts'] === $hour ) {
204 - $buf[ count( $buf ) - 1 ]['hits'] += $pending['hit'];
205 - $buf[ count( $buf ) - 1 ]['misses'] += $pending['miss'];
206 - $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;
207 431 }
208 432
209 433 if ( ! $updated ) {
210 434 $buf[] = array(
211 - 'ts' => $hour,
212 - 'hits' => $pending['hit'],
213 - 'misses' => $pending['miss'],
435 + 'ts' => $hour,
436 + 'hits' => $pending['hit'],
437 + 'misses' => $pending['miss'],
438 + 'excluded' => $pending['excluded'],
214 439 );
215 440 while ( count( $buf ) > self::MAX_BUCKETS ) {
216 441 array_shift( $buf );
217 442 }
@@ -216,7 +441,72 @@
216 441 array_shift( $buf );
217 442 }
218 443 }
219 444
220 - 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;
221 511 }
222 512 }