PluginProbe
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN / 1.0.7
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN v1.0.7
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.0.7, at includes/class-hit-counter.php

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