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

223 lines 6.6 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 * @var array<int,int> Pending increments keyed by metric ('hit'|'miss').
34 * Flushed to the transient on shutdown.
35 */
36 private static $pending = array( 'hit' => 0, 'miss' => 0 );
37
38 /**
39 * @var bool Whether the shutdown flush is already registered.
40 */
41 private static $shutdown_registered = false;
42
43 public static function record_hit(): void {
44 ++self::$pending['hit'];
45 self::ensure_shutdown_flush();
46 }
47
48 public static function record_miss(): void {
49 ++self::$pending['miss'];
50 self::ensure_shutdown_flush();
51 }
52
53 /**
54 * Add `$count` HITs in one shot. Used by collect_nginx_log_hits()
55 * to attribute many HITs served directly by nginx (bypassing PHP)
56 * to the counter once we've drained the log file.
57 */
58 public static function record_hits_batch( int $count ): void {
59 if ( $count <= 0 ) {
60 return;
61 }
62 self::$pending['hit'] += $count;
63 self::ensure_shutdown_flush();
64 }
65
66 /**
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.
72 *
73 * Returns the number of HITs collected (0 if the log is missing,
74 * empty, or the rewrite block isn't engaged).
75 *
76 * Concurrency: file is opened with LOCK_EX before the read/truncate
77 * round-trip so a concurrent nginx write can't lose entries. Nginx
78 * uses buffer=16k flush=10s on its access_log so writes are batched
79 * and the lock contention is negligible.
80 */
81 public static function collect_nginx_log_hits(): int {
82 $path = WP_CONTENT_DIR . '/cache/xspeed/hits.log';
83 if ( ! file_exists( $path ) ) {
84 return 0;
85 }
86 if ( filesize( $path ) === 0 ) {
87 return 0;
88 }
89 // 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.
90 $fp = @fopen( $path, 'r+' );
91 if ( ! $fp ) {
92 return 0;
93 }
94 // Non-blocking exclusive lock — if nginx is mid-write we just skip
95 // this collection and try again on the next dashboard load.
96 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_flock -- See fopen rationale.
97 if ( ! @flock( $fp, LOCK_EX | LOCK_NB ) ) { // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
98 fclose( $fp );
99 return 0;
100 }
101 $count = 0;
102 while ( ( $line = fgets( $fp ) ) !== false ) {
103 if ( '' !== rtrim( $line ) ) {
104 ++$count;
105 }
106 }
107 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_ftruncate -- See fopen rationale.
108 ftruncate( $fp, 0 );
109 flock( $fp, LOCK_UN );
110 fclose( $fp );
111
112 if ( $count > 0 ) {
113 self::record_hits_batch( $count );
114 // Flush immediately — the next read of totals_24h() happens
115 // inline in Cache::get_stats(), before register_shutdown_function
116 // could fire. Without this, the dashboard sees stale numbers
117 // and the just-drained HITs appear on the FOLLOWING refresh.
118 self::flush_pending();
119 }
120 return $count;
121 }
122
123 /**
124 * Returns up to MAX_BUCKETS most-recent hourly buckets oldest →
125 * newest. Each bucket: [ts => unix hour-start, hits => int, misses
126 * => int ].
127 *
128 * @return array<int,array{ts:int,hits:int,misses:int}>
129 */
130 public static function buckets(): array {
131 $buf = get_transient( self::TRANSIENT_KEY );
132 if ( ! is_array( $buf ) ) {
133 return array();
134 }
135 // Defensive — strip anything not shaped right.
136 $out = array();
137 foreach ( $buf as $b ) {
138 if ( is_array( $b ) && isset( $b['ts'], $b['hits'], $b['misses'] ) ) {
139 $out[] = array(
140 'ts' => (int) $b['ts'],
141 'hits' => (int) $b['hits'],
142 'misses' => (int) $b['misses'],
143 );
144 }
145 }
146 return $out;
147 }
148
149 /**
150 * Totals over the last 24h (sum across all buckets).
151 *
152 * @return array{hits:int,misses:int,ratio:float}
153 */
154 public static function totals_24h(): array {
155 $buckets = self::buckets();
156 $hits = 0;
157 $misses = 0;
158 foreach ( $buckets as $b ) {
159 $hits += $b['hits'];
160 $misses += $b['misses'];
161 }
162 $total = $hits + $misses;
163 return array(
164 'hits' => $hits,
165 'misses' => $misses,
166 'ratio' => $total > 0 ? round( $hits / $total, 4 ) : 0.0,
167 );
168 }
169
170 public static function reset(): void {
171 delete_transient( self::TRANSIENT_KEY );
172 self::$pending = array( 'hit' => 0, 'miss' => 0 );
173 }
174
175 /**
176 * One-shot register on first record_* call this request.
177 */
178 private static function ensure_shutdown_flush(): void {
179 if ( self::$shutdown_registered ) {
180 return;
181 }
182 self::$shutdown_registered = true;
183 register_shutdown_function( array( __CLASS__, 'flush_pending' ) );
184 }
185
186 /**
187 * Flush in-process counters into the transient. Bucketed by current
188 * hour. New hour → append a bucket and drop the oldest if we exceed
189 * MAX_BUCKETS.
190 */
191 public static function flush_pending(): void {
192 $pending = self::$pending;
193 if ( 0 === $pending['hit'] && 0 === $pending['miss'] ) {
194 return;
195 }
196 self::$pending = array( 'hit' => 0, 'miss' => 0 );
197
198 $hour = (int) ( time() - ( time() % 3600 ) );
199 $buf = self::buckets();
200 $last = end( $buf );
201 $updated = false;
202
203 if ( $last && $last['ts'] === $hour ) {
204 $buf[ count( $buf ) - 1 ]['hits'] += $pending['hit'];
205 $buf[ count( $buf ) - 1 ]['misses'] += $pending['miss'];
206 $updated = true;
207 }
208
209 if ( ! $updated ) {
210 $buf[] = array(
211 'ts' => $hour,
212 'hits' => $pending['hit'],
213 'misses' => $pending['miss'],
214 );
215 while ( count( $buf ) > self::MAX_BUCKETS ) {
216 array_shift( $buf );
217 }
218 }
219
220 set_transient( self::TRANSIENT_KEY, $buf, self::TTL );
221 }
222 }
223