PluginProbe
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN / 1.1.1
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN v1.1.1
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 1.1.8 All 29 releases
xspeed / includes / class-hit-counter.php

class-hit-counter.php in xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN 1.1.1, at includes/class-hit-counter.php

453 lines 16.1 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Hit_Counter — rolling 24h hits + misses for cache requests.
4 *
5 * Storage: one transient `xspeed_hit_buffer` containing a list of up to
6 * 24 hourly buckets. Each bucket: [hour_start_ts, hits, misses]. Bucket
7 * keyed by floor(time()/3600); old buckets drop off when we push a new
8 * hour. Transient TTL set to 25 hours so an idle site doesn't lose its
9 * history immediately after going quiet.
10 *
11 * Writes happen on every cached HIT and every MISS (Cache.php records
12 * via the static record_* methods). We absorb the cost in an in-process
13 * static accumulator that flushes to the transient once per request via
14 * register_shutdown_function, so the served-from-disk hot path pays
15 * nothing.
16 *
17 * @package XSpeed
18 */
19
20 declare(strict_types=1);
21
22 namespace XSpeed;
23
24 defined( 'ABSPATH' ) || exit;
25
26 final class Hit_Counter {
27
28 public const TRANSIENT_KEY = 'xspeed_hit_buffer';
29 public const TTL = 90000; // 25h
30 public const MAX_BUCKETS = 24;
31
32 /**
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.
47 */
48 public const OPT_KEY = 'xspeed_hit_buffer';
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
56 /**
57 * @var array<int,int> Pending increments keyed by metric ('hit'|'miss').
58 * Flushed to the transient on shutdown.
59 */
60 private static $pending = array( 'hit' => 0, 'miss' => 0 );
61
62 /**
63 * @var bool Whether the shutdown flush is already registered.
64 */
65 private static $shutdown_registered = false;
66
67 public static function record_hit(): void {
68 ++self::$pending['hit'];
69 self::ensure_shutdown_flush();
70 }
71
72 public static function record_miss(): void {
73 ++self::$pending['miss'];
74 // Flush misses INLINE, not at shutdown. A MISS is recorded ONLY here
75 // (HITs additionally have the durable hits.log drain as a backstop),
76 // so if a miss flush is ever dropped the dashboard ratio skews toward
77 // 100%. Flushing inline guarantees the miss is committed to the
78 // options-backed buffer (see OPT_KEY) within this request, before any
79 // shutdown-time object-cache teardown could interfere. Misses are
80 // low-frequency (one per page per cache fill), so the inline write
81 // cost is negligible; HITs stay deferred (high-volume).
82 self::flush_pending();
83 }
84
85 /**
86 * Add `$count` HITs in one shot. Used by collect_nginx_log_hits()
87 * to attribute many HITs served directly by nginx (bypassing PHP)
88 * to the counter once we've drained the log file.
89 */
90 public static function record_hits_batch( int $count ): void {
91 if ( $count <= 0 ) {
92 return;
93 }
94 self::$pending['hit'] += $count;
95 self::ensure_shutdown_flush();
96 }
97
98 /**
99 * Drain the HITs log file at wp-content/cache/xspeed/hits.log. Two
100 * serve paths that can't call record_hit() inline append one line per
101 * HIT here: the nginx server-level rewrite block (see
102 * Cache::nginx_snippet(), serves without ever reaching PHP) and the
103 * advanced-cache.php drop-in (runs before WordPress loads, so
104 * Hit_Counter isn't available). This method reads the line count,
105 * truncates the file, and folds the count into Hit_Counter via
106 * record_hits_batch — so both uncountable-inline paths still show up
107 * in the dashboard hit-ratio on the next load.
108 *
109 * Returns the number of HITs collected (0 if the log is missing,
110 * empty, or the rewrite block isn't engaged).
111 *
112 * Concurrency: file is opened with LOCK_EX before the read/truncate
113 * round-trip so a concurrent nginx write can't lose entries. Nginx
114 * uses buffer=16k flush=10s on its access_log so writes are batched
115 * and the lock contention is negligible.
116 */
117 public static function collect_nginx_log_hits(): int {
118 // Lives under uploads/, not the cache dir — see Cache::hits_log_dir()
119 // (FBS-82478: a cache-dir access_log can take nginx down on purge/
120 // uninstall).
121 $path = Cache::hits_log_path();
122 if ( ! file_exists( $path ) ) {
123 return 0;
124 }
125 if ( filesize( $path ) === 0 ) {
126 return 0;
127 }
128 // 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.
129 $fp = @fopen( $path, 'r+' );
130 if ( ! $fp ) {
131 return 0;
132 }
133 // Non-blocking exclusive lock — if nginx is mid-write we just skip
134 // this collection and try again on the next dashboard load.
135 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_flock -- See fopen rationale.
136 if ( ! @flock( $fp, LOCK_EX | LOCK_NB ) ) { // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
137 fclose( $fp ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- pairs with the flock'd fopen above; WP_Filesystem can't model flock.
138 return 0;
139 }
140 $count = 0;
141 while ( ( $line = fgets( $fp ) ) !== false ) {
142 if ( '' !== rtrim( $line ) ) {
143 ++$count;
144 }
145 }
146 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_ftruncate -- See fopen rationale.
147 ftruncate( $fp, 0 );
148 flock( $fp, LOCK_UN );
149 fclose( $fp ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- pairs with the flock'd fopen above; WP_Filesystem can't model flock.
150
151 if ( $count > 0 ) {
152 self::record_hits_batch( $count );
153 // Flush immediately — the next read of totals_24h() happens
154 // inline in Cache::get_stats(), before register_shutdown_function
155 // could fire. Without this, the dashboard sees stale numbers
156 // and the just-drained HITs appear on the FOLLOWING refresh.
157 self::flush_pending();
158 }
159 return $count;
160 }
161
162 /** Option key storing the last-scanned byte offset of the access log. */
163 public const SERVER_LOG_OFFSET_OPT = 'xspeed_access_log_offset';
164
165 /**
166 * Count Apache/LiteSpeed static-rewrite HITs by scanning the web
167 * server's access log.
168 *
169 * On Apache/LiteSpeed a cache HIT is served straight from the
170 * `xspeed-static/` tree by a `.htaccess` RewriteRule — the request
171 * never reaches PHP, so (unlike the nginx path, which logs to our own
172 * dedicated hits.log) there's no inline hook to call record_hit().
173 * Instead we read the server's own access log incrementally: every
174 * request whose logged path contains our static-cache dir was a HIT
175 * served below PHP.
176 *
177 * Incremental + safe:
178 * - We remember a byte offset (SERVER_LOG_OFFSET_OPT) and only read
179 * bytes appended since last time — O(new traffic), not O(log size).
180 * - If the log shrank (rotation/truncation) we reset the offset to 0
181 * and rescan from the top once, so a rotation never double-counts
182 * or permanently desyncs.
183 * - We never write to the log, only read; failure is silent.
184 *
185 * Returns 0 (and is a no-op) when no readable access log exists — the
186 * common managed-host case. The drop-in/PHP path still counts its own
187 * HITs, so hit-ratio degrades to "PHP-served hits only" rather than 0.
188 *
189 * @return int HITs folded in this call.
190 */
191 public static function collect_server_log_hits(): int {
192 // Apache only. nginx writes its own dedicated hits.log (drained by
193 // collect_nginx_log_hits); LiteSpeed routes hits through the PHP
194 // drop-in (which also appends to that hits.log) because its
195 // .htaccess can't header/log a static serve — see
196 // Cache::static_rewrite_allowed(). So Apache is the lone server that
197 // serves static hits below PHP yet logs them to the SERVER's access
198 // log, which is what we scan here.
199 if ( Server::APACHE !== Server::type() ) {
200 return 0;
201 }
202
203 $path = Server::access_log_path();
204 if ( '' === $path ) {
205 return 0;
206 }
207
208 $size = @filesize( $path ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- log may vanish on rotation between checks.
209 if ( false === $size ) {
210 return 0;
211 }
212
213 $offset = (int) get_option( self::SERVER_LOG_OFFSET_OPT, 0 );
214 if ( $offset > $size ) {
215 // Log was rotated/truncated since last scan — start over so we
216 // don't seek past EOF and miss the new file's lines.
217 $offset = 0;
218 }
219 if ( $offset === $size ) {
220 return 0; // Nothing new since last drain.
221 }
222
223 // 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.
224 $fp = @fopen( $path, 'r' );
225 if ( ! $fp ) {
226 return 0;
227 }
228 if ( $offset > 0 ) {
229 fseek( $fp, $offset );
230 }
231
232 // The static-cache dir, as it appears in a logged request path. We
233 // match on the request-target substring so the access-log format
234 // (combined/common/custom) doesn't matter — every format includes
235 // the request line.
236 $needle = '/' . trim( str_replace( ABSPATH, '', XSPEED_CACHE_STATIC_DIR ), '/' );
237 $count = 0;
238 while ( ( $line = fgets( $fp ) ) !== false ) {
239 // Only count GET requests that landed on the static tree. The
240 // "GET " + needle pairing avoids counting our own loopback
241 // probe writes or unrelated dir listings.
242 if ( false !== strpos( $line, $needle ) && false !== strpos( $line, 'GET ' ) ) {
243 ++$count;
244 }
245 }
246 $new_offset = ftell( $fp );
247 fclose( $fp ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- pairs with the read-only fopen above.
248
249 // Persist the offset even when count is 0 so we don't re-scan the
250 // same non-matching bytes every dashboard load.
251 update_option( self::SERVER_LOG_OFFSET_OPT, (int) $new_offset, false );
252
253 if ( $count > 0 ) {
254 self::record_hits_batch( $count );
255 self::flush_pending();
256 }
257 return $count;
258 }
259
260 /**
261 * Returns up to MAX_BUCKETS most-recent hourly buckets oldest →
262 * newest. Each bucket: [ts => unix hour-start, hits => int, misses
263 * => int ].
264 *
265 * @return array<int,array{ts:int,hits:int,misses:int}>
266 */
267 /**
268 * Read the bucket buffer straight from the options table, busting any
269 * stale per-request object-cache copy first so a non-persistent cache
270 * can never shadow the committed DB value. See OPT_KEY docblock.
271 *
272 * @return mixed Raw stored value (array on success).
273 */
274 private static function read_buffer() {
275 // Drop the cached 'options' entry for our key so get_option() falls
276 // through to the DB. Harmless on a persistent cache (it just reloads
277 // from the DB once); essential on a non-persistent one.
278 \wp_cache_delete( self::OPT_KEY, 'options' );
279 return get_option( self::OPT_KEY, array() );
280 }
281
282 private static function write_buffer( array $buf ): void {
283 // Autoload 'no' — the buffer is read only in admin/stats contexts, so
284 // it must never inflate the frontend alloptions payload.
285 if ( false === get_option( self::OPT_KEY, false ) ) {
286 add_option( self::OPT_KEY, $buf, '', 'no' );
287 return;
288 }
289 update_option( self::OPT_KEY, $buf );
290 }
291
292 public static function buckets(): array {
293 $buf = self::read_buffer();
294 if ( ! is_array( $buf ) ) {
295 return array();
296 }
297 // Defensive — strip anything not shaped right.
298 $out = array();
299 foreach ( $buf as $b ) {
300 if ( is_array( $b ) && isset( $b['ts'], $b['hits'], $b['misses'] ) ) {
301 $out[] = array(
302 'ts' => (int) $b['ts'],
303 'hits' => (int) $b['hits'],
304 'misses' => (int) $b['misses'],
305 );
306 }
307 }
308 return $out;
309 }
310
311 /**
312 * Totals over the last 24h (sum across all buckets).
313 *
314 * @return array{hits:int,misses:int,ratio:float}
315 */
316 public static function totals_24h(): array {
317 $buckets = self::buckets();
318 $hits = 0;
319 $misses = 0;
320 foreach ( $buckets as $b ) {
321 $hits += $b['hits'];
322 $misses += $b['misses'];
323 }
324 $total = $hits + $misses;
325 return array(
326 'hits' => $hits,
327 'misses' => $misses,
328 'ratio' => $total > 0 ? round( $hits / $total, 4 ) : 0.0,
329 );
330 }
331
332 public static function reset(): void {
333 delete_transient( self::TRANSIENT_KEY );
334 // The bucket buffer lives in the OPT_KEY option (migrated off the
335 // transient); reset() must clear it too, or record→reset leaves the
336 // old hit/miss buckets behind and buckets() still reports them.
337 delete_option( self::OPT_KEY );
338 \wp_cache_delete( self::OPT_KEY, 'options' );
339 delete_option( self::SERVER_LOG_OFFSET_OPT );
340 delete_option( self::DAILY_OPT );
341 self::$pending = array( 'hit' => 0, 'miss' => 0 );
342 }
343
344 /**
345 * One-shot register on first record_* call this request.
346 */
347 private static function ensure_shutdown_flush(): void {
348 if ( self::$shutdown_registered ) {
349 return;
350 }
351 self::$shutdown_registered = true;
352 register_shutdown_function( array( __CLASS__, 'flush_pending' ) );
353 }
354
355 /**
356 * Flush in-process counters into the transient. Bucketed by current
357 * hour. New hour → append a bucket and drop the oldest if we exceed
358 * MAX_BUCKETS.
359 */
360 public static function flush_pending(): void {
361 $pending = self::$pending;
362 if ( 0 === $pending['hit'] && 0 === $pending['miss'] ) {
363 return;
364 }
365 self::$pending = array( 'hit' => 0, 'miss' => 0 );
366
367 $hour = (int) ( time() - ( time() % 3600 ) );
368 $buf = self::buckets();
369 $last = end( $buf );
370 $updated = false;
371
372 if ( $last && $last['ts'] === $hour ) {
373 $buf[ count( $buf ) - 1 ]['hits'] += $pending['hit'];
374 $buf[ count( $buf ) - 1 ]['misses'] += $pending['miss'];
375 $updated = true;
376 }
377
378 if ( ! $updated ) {
379 $buf[] = array(
380 'ts' => $hour,
381 'hits' => $pending['hit'],
382 'misses' => $pending['miss'],
383 );
384 while ( count( $buf ) > self::MAX_BUCKETS ) {
385 array_shift( $buf );
386 }
387 }
388
389 self::write_buffer( $buf );
390 self::bump_daily( $pending['hit'], $pending['miss'] );
391 }
392
393 /**
394 * Fold the just-flushed counts into the persistent daily series. The
395 * hourly buckets expire after ~25h; this option is what makes 7/30-day
396 * hit-ratio trends possible (issue #44). Autoload off — it's only read
397 * by the dashboard/REST, never on the frontend hot path.
398 */
399 private static function bump_daily( int $hits, int $misses ): void {
400 if ( $hits <= 0 && $misses <= 0 ) {
401 return;
402 }
403 $day = gmdate( 'Y-m-d' );
404 $series = get_option( self::DAILY_OPT, array() );
405 if ( ! is_array( $series ) ) {
406 $series = array();
407 }
408 if ( ! isset( $series[ $day ] ) || ! is_array( $series[ $day ] ) ) {
409 $series[ $day ] = array(
410 'hits' => 0,
411 'misses' => 0,
412 );
413 }
414 $series[ $day ]['hits'] += $hits;
415 $series[ $day ]['misses'] += $misses;
416 if ( count( $series ) > self::DAILY_MAX_DAYS ) {
417 ksort( $series );
418 $series = array_slice( $series, -self::DAILY_MAX_DAYS, null, true );
419 }
420 update_option( self::DAILY_OPT, $series, false );
421 }
422
423 /**
424 * The stored daily hit/miss series, oldest→newest, at most $days rows.
425 *
426 * @return array<int,array{date:string,hits:int,misses:int,ratio:float}>
427 */
428 public static function daily_series( int $days = 30 ): array {
429 $series = get_option( self::DAILY_OPT, array() );
430 if ( ! is_array( $series ) || empty( $series ) ) {
431 return array();
432 }
433 ksort( $series );
434 $series = array_slice( $series, -max( 1, $days ), null, true );
435 $out = array();
436 foreach ( $series as $date => $row ) {
437 if ( ! is_array( $row ) ) {
438 continue;
439 }
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,
448 );
449 }
450 return $out;
451 }
452 }
453