PluginProbe
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN / 1.3.7
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN v1.3.7
1.3.7 1.3.6 1.3.5 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 All 33 releases
← All changes | includes/class-hit-counter.php +426 -27 1.0.2 → 1.3.7 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,266 @@
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 + * The bot / crawler / scanner alternation, without delimiters so the
93 + * drop-in can compose it — see excluded_ua_regex().
94 + */
95 + public const BOT_UA_PATTERN = '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';
96 +
97 + /**
98 + * Whether a User-Agent is a known bot / crawler / vulnerability scanner —
99 + * its cache misses are cache-warming or hostile noise, not a signal of how
100 + * the cache serves real visitors. Deliberately broad: matches the common
101 + * crawler tokens plus the generic markers scanners and libraries carry.
102 + * Unit-tested; no longer pure — Self_Traffic::is_self() runs the
103 + * xspeed_self_user_agents filter, so the answer can vary per site. (#118)
104 + */
105 + public static function is_bot_ua( string $ua ): bool {
106 + if ( '' === $ua ) {
107 + // No UA at all is overwhelmingly automated traffic, not a browser.
108 + return true;
109 + }
110 + // Our own warmer, benchmark and verifier are warming the cache, not
111 + // visiting it: `xSpeed-Warmer`, `xSpeed Benchmark`, and the rest.
112 + // Callers with a request also check Self_Traffic::request_is_marked().
113 + if ( Self_Traffic::is_self( $ua ) ) {
114 + return true;
115 + }
116 + return 1 === preg_match( '~(' . self::BOT_UA_PATTERN . ')~i', $ua );
117 + }
118 +
119 + /**
120 + * The "do not count this user agent" alternation: bots and scanners,
121 + * plus the fragments xSpeed's own requests carry. A renamed warmer is
122 + * not in it on purpose; that request is recognised by
123 + * Self_Traffic::HEADER, because its UA may be a real browser's.
124 + *
125 + * Baked into the drop-in at install time (`@@XSPEED_HIT_EXCLUDE_RE@@`).
126 + * The drop-in runs before WordPress, so it cannot ask this class and the
127 + * hits.log line it writes carries no user agent — nothing downstream can
128 + * reclassify the line later, which is why the decision has to travel
129 + * with the file. A hardcoded copy of the fragments drifted instead: it
130 + * excluded the warmer but still counted every crawler HIT, and it could
131 + * not know about an overridden `xspeed_preloader_user_agent`.
132 + */
133 + public static function excluded_ua_regex(): string {
134 + $parts = array( self::BOT_UA_PATTERN );
135 + foreach ( Self_Traffic::agents() as $agent ) {
136 + $parts[] = preg_quote( $agent, '#' );
137 + }
138 + return implode( '|', $parts );
139 + }
140 +
48 141 public static function record_miss(): void {
49 142 ++self::$pending['miss'];
143 + // Flush misses INLINE, not at shutdown. A MISS is recorded ONLY here
144 + // (HITs additionally have the durable hits.log drain as a backstop),
145 + // so if a miss flush is ever dropped the dashboard ratio skews toward
146 + // 100%. Flushing inline guarantees the miss is committed to the
147 + // options-backed buffer (see OPT_KEY) within this request, before any
148 + // shutdown-time object-cache teardown could interfere. Misses are
149 + // low-frequency (one per page per cache fill), so the inline write
150 + // cost is negligible; HITs stay deferred (high-volume).
151 + self::flush_pending();
152 + }
153 +
154 + /**
155 + * Add `$count` HITs in one shot. Used by collect_nginx_log_hits()
156 + * to attribute many HITs served directly by nginx (bypassing PHP)
157 + * to the counter once we've drained the log file.
158 + */
159 + public static function record_hits_batch( int $count ): void {
160 + if ( $count <= 0 ) {
161 + return;
162 + }
163 + self::$pending['hit'] += $count;
50 164 self::ensure_shutdown_flush();
51 165 }
52 166
53 167 /**
168 + * Drain the HITs log file at wp-content/cache/xspeed/hits.log. Two
169 + * serve paths that can't call record_hit() inline append one line per
170 + * HIT here: the nginx server-level rewrite block (see
171 + * Cache::nginx_snippet(), serves without ever reaching PHP) and the
172 + * advanced-cache.php drop-in (runs before WordPress loads, so
173 + * Hit_Counter isn't available). This method reads the line count,
174 + * truncates the file, and folds the count into Hit_Counter via
175 + * record_hits_batch — so both uncountable-inline paths still show up
176 + * in the dashboard hit-ratio on the next load.
177 + *
178 + * Returns the number of HITs collected (0 if the log is missing,
179 + * empty, or the rewrite block isn't engaged).
180 + *
181 + * Concurrency: file is opened with LOCK_EX before the read/truncate
182 + * round-trip so a concurrent nginx write can't lose entries. Nginx
183 + * uses buffer=16k flush=10s on its access_log so writes are batched
184 + * and the lock contention is negligible.
185 + */
186 + public static function collect_nginx_log_hits(): int {
187 + // Lives under uploads/, not the cache dir — see Cache::hits_log_dir()
188 + // (FBS-82478: a cache-dir access_log can take nginx down on purge/
189 + // uninstall).
190 + $path = Cache::hits_log_path();
191 + if ( ! file_exists( $path ) ) {
192 + return 0;
193 + }
194 + if ( filesize( $path ) === 0 ) {
195 + return 0;
196 + }
197 + // 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.
198 + $fp = @fopen( $path, 'r+' );
199 + if ( ! $fp ) {
200 + return 0;
201 + }
202 + // Non-blocking exclusive lock — if nginx is mid-write we just skip
203 + // this collection and try again on the next dashboard load.
204 + // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_flock -- See fopen rationale.
205 + if ( ! @flock( $fp, LOCK_EX | LOCK_NB ) ) { // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
206 + fclose( $fp ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- pairs with the flock'd fopen above; WP_Filesystem can't model flock.
207 + return 0;
208 + }
209 + $count = 0;
210 + while ( ( $line = fgets( $fp ) ) !== false ) {
211 + if ( '' !== rtrim( $line ) ) {
212 + ++$count;
213 + }
214 + }
215 + // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_ftruncate -- See fopen rationale.
216 + ftruncate( $fp, 0 );
217 + flock( $fp, LOCK_UN );
218 + fclose( $fp ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- pairs with the flock'd fopen above; WP_Filesystem can't model flock.
219 +
220 + if ( $count > 0 ) {
221 + self::record_hits_batch( $count );
222 + // Flush immediately — the next read of totals_24h() happens
223 + // inline in Cache::get_stats(), before register_shutdown_function
224 + // could fire. Without this, the dashboard sees stale numbers
225 + // and the just-drained HITs appear on the FOLLOWING refresh.
226 + self::flush_pending();
227 + }
228 + return $count;
229 + }
230 +
231 + /** Option key storing the last-scanned byte offset of the access log. */
232 + public const SERVER_LOG_OFFSET_OPT = 'xspeed_access_log_offset';
233 +
234 + /**
235 + * Count Apache/LiteSpeed static-rewrite HITs by scanning the web
236 + * server's access log.
237 + *
238 + * On Apache/LiteSpeed a cache HIT is served straight from the
239 + * `xspeed-static/` tree by a `.htaccess` RewriteRule — the request
240 + * never reaches PHP, so (unlike the nginx path, which logs to our own
241 + * dedicated hits.log) there's no inline hook to call record_hit().
242 + * Instead we read the server's own access log incrementally: every
243 + * request whose logged path contains our static-cache dir was a HIT
244 + * served below PHP.
245 + *
246 + * Incremental + safe:
247 + * - We remember a byte offset (SERVER_LOG_OFFSET_OPT) and only read
248 + * bytes appended since last time — O(new traffic), not O(log size).
249 + * - If the log shrank (rotation/truncation) we reset the offset to 0
250 + * and rescan from the top once, so a rotation never double-counts
251 + * or permanently desyncs.
252 + * - We never write to the log, only read; failure is silent.
253 + *
254 + * Returns 0 (and is a no-op) when no readable access log exists — the
255 + * common managed-host case. The drop-in/PHP path still counts its own
256 + * HITs, so hit-ratio degrades to "PHP-served hits only" rather than 0.
257 + *
258 + * @return int HITs folded in this call.
259 + */
260 + public static function collect_server_log_hits(): int {
261 + // Apache only. nginx writes its own dedicated hits.log (drained by
262 + // collect_nginx_log_hits); LiteSpeed routes hits through the PHP
263 + // drop-in (which also appends to that hits.log) because its
264 + // .htaccess can't header/log a static serve — see
265 + // Cache::static_rewrite_allowed(). So Apache is the lone server that
266 + // serves static hits below PHP yet logs them to the SERVER's access
267 + // log, which is what we scan here.
268 + //
269 + // LiteSpeed stays out even with the Static Fast Path opt-in (#509):
270 + // its access log records the ORIGINAL request line ("GET / …"), not
271 + // the rewritten static-file path, so the needle below can never
272 + // match and scanning would only pretend to count. Verified on
273 + // OpenLiteSpeed 1.8. Those hits are genuinely uncounted, which the
274 + // dashboard discloses via stats.static_hits_uncounted.
275 + if ( Server::APACHE !== Server::type() ) {
276 + return 0;
277 + }
278 +
279 + $path = Server::access_log_path();
280 + if ( '' === $path ) {
281 + return 0;
282 + }
283 +
284 + $size = @filesize( $path ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- log may vanish on rotation between checks.
285 + if ( false === $size ) {
286 + return 0;
287 + }
288 +
289 + $offset = (int) get_option( self::SERVER_LOG_OFFSET_OPT, 0 );
290 + if ( $offset > $size ) {
291 + // Log was rotated/truncated since last scan — start over so we
292 + // don't seek past EOF and miss the new file's lines.
293 + $offset = 0;
294 + }
295 + if ( $offset === $size ) {
296 + return 0; // Nothing new since last drain.
297 + }
298 +
299 + // 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.
300 + $fp = @fopen( $path, 'r' );
301 + if ( ! $fp ) {
302 + return 0;
303 + }
304 + if ( $offset > 0 ) {
305 + fseek( $fp, $offset );
306 + }
307 +
308 + // The static-cache dir, as it appears in a logged request path. We
309 + // match on the request-target substring so the access-log format
310 + // (combined/common/custom) doesn't matter — every format includes
311 + // the request line.
312 + $needle = '/' . trim( str_replace( ABSPATH, '', XSPEED_CACHE_STATIC_DIR ), '/' );
313 + $count = 0;
314 + while ( ( $line = fgets( $fp ) ) !== false ) {
315 + // Only count GET requests that landed on the static tree. The
316 + // "GET " + needle pairing avoids counting our own loopback
317 + // probe writes or unrelated dir listings.
318 + if ( false !== strpos( $line, $needle ) && false !== strpos( $line, 'GET ' ) ) {
319 + ++$count;
320 + }
321 + }
322 + $new_offset = ftell( $fp );
323 + fclose( $fp ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- pairs with the read-only fopen above.
324 +
325 + // Persist the offset even when count is 0 so we don't re-scan the
326 + // same non-matching bytes every dashboard load.
327 + update_option( self::SERVER_LOG_OFFSET_OPT, (int) $new_offset, false );
328 +
329 + if ( $count > 0 ) {
330 + self::record_hits_batch( $count );
331 + self::flush_pending();
332 + }
333 + return $count;
334 + }
335 +
336 + /**
54 337 * Returns up to MAX_BUCKETS most-recent hourly buckets oldest →
55 338 * newest. Each bucket: [ts => unix hour-start, hits => int, misses
56 339 * => int ].
57 340 *
@@ -56,10 +339,35 @@
56 339 * => int ].
57 340 *
58 341 * @return array<int,array{ts:int,hits:int,misses:int}>
59 342 */
343 + /**
344 + * Read the bucket buffer straight from the options table, busting any
345 + * stale per-request object-cache copy first so a non-persistent cache
346 + * can never shadow the committed DB value. See OPT_KEY docblock.
347 + *
348 + * @return mixed Raw stored value (array on success).
349 + */
350 + private static function read_buffer() {
351 + // Drop the cached 'options' entry for our key so get_option() falls
352 + // through to the DB. Harmless on a persistent cache (it just reloads
353 + // from the DB once); essential on a non-persistent one.
354 + \wp_cache_delete( self::OPT_KEY, 'options' );
355 + return get_option( self::OPT_KEY, array() );
356 + }
357 +
358 + private static function write_buffer( array $buf ): void {
359 + // Autoload 'no' — the buffer is read only in admin/stats contexts, so
360 + // it must never inflate the frontend alloptions payload.
361 + if ( false === get_option( self::OPT_KEY, false ) ) {
362 + add_option( self::OPT_KEY, $buf, '', 'no' );
363 + return;
364 + }
365 + update_option( self::OPT_KEY, $buf );
366 + }
367 +
60 368 public static function buckets(): array {
61 - $buf = get_transient( self::TRANSIENT_KEY );
369 + $buf = self::read_buffer();
62 370 if ( ! is_array( $buf ) ) {
63 371 return array();
64 372 }
65 373 // Defensive — strip anything not shaped right.
@@ -66,11 +374,13 @@
66 374 $out = array();
67 375 foreach ( $buf as $b ) {
68 376 if ( is_array( $b ) && isset( $b['ts'], $b['hits'], $b['misses'] ) ) {
69 377 $out[] = array(
70 - 'ts' => (int) $b['ts'],
71 - 'hits' => (int) $b['hits'],
72 - 'misses' => (int) $b['misses'],
378 + 'ts' => (int) $b['ts'],
379 + 'hits' => (int) $b['hits'],
380 + 'misses' => (int) $b['misses'],
381 + // Older buckets (pre-#118) have no 'excluded' key — default 0.
382 + 'excluded' => (int) ( $b['excluded'] ?? 0 ),
73 383 );
74 384 }
75 385 }
76 386 return $out;
@@ -76,31 +386,48 @@
76 386 return $out;
77 387 }
78 388
79 389 /**
80 - * Totals over the last 24h (sum across all buckets).
390 + * Totals over the last 24h (sum across all buckets). `ratio` is computed
391 + * over hits + real misses only; `excluded` (404s + bots) is reported
392 + * alongside but kept OUT of the denominator so a scanner flood can't crater
393 + * the number. (#118)
81 394 *
82 - * @return array{hits:int,misses:int,ratio:float}
395 + * @return array{hits:int,misses:int,excluded:int,ratio:float}
83 396 */
84 397 public static function totals_24h(): array {
85 - $buckets = self::buckets();
86 - $hits = 0;
87 - $misses = 0;
398 + $buckets = self::buckets();
399 + $hits = 0;
400 + $misses = 0;
401 + $excluded = 0;
88 402 foreach ( $buckets as $b ) {
89 - $hits += $b['hits'];
90 - $misses += $b['misses'];
403 + $hits += $b['hits'];
404 + $misses += $b['misses'];
405 + $excluded += $b['excluded'];
91 406 }
92 407 $total = $hits + $misses;
93 408 return array(
94 - 'hits' => $hits,
95 - 'misses' => $misses,
96 - 'ratio' => $total > 0 ? round( $hits / $total, 4 ) : 0.0,
409 + 'hits' => $hits,
410 + 'misses' => $misses,
411 + 'excluded' => $excluded,
412 + 'ratio' => $total > 0 ? round( $hits / $total, 4 ) : 0.0,
97 413 );
98 414 }
99 415
100 416 public static function reset(): void {
101 417 delete_transient( self::TRANSIENT_KEY );
102 - self::$pending = array( 'hit' => 0, 'miss' => 0 );
418 + // The bucket buffer lives in the OPT_KEY option (migrated off the
419 + // transient); reset() must clear it too, or record→reset leaves the
420 + // old hit/miss buckets behind and buckets() still reports them.
421 + delete_option( self::OPT_KEY );
422 + \wp_cache_delete( self::OPT_KEY, 'options' );
423 + delete_option( self::SERVER_LOG_OFFSET_OPT );
424 + delete_option( self::DAILY_OPT );
425 + self::$pending = array(
426 + 'hit' => 0,
427 + 'miss' => 0,
428 + 'excluded' => 0,
429 + );
103 430 }
104 431
105 432 /**
106 433 * One-shot register on first record_* call this request.
@@ -119,12 +446,16 @@
119 446 * MAX_BUCKETS.
120 447 */
121 448 public static function flush_pending(): void {
122 449 $pending = self::$pending;
123 - if ( 0 === $pending['hit'] && 0 === $pending['miss'] ) {
450 + if ( 0 === $pending['hit'] && 0 === $pending['miss'] && 0 === $pending['excluded'] ) {
124 451 return;
125 452 }
126 - self::$pending = array( 'hit' => 0, 'miss' => 0 );
453 + self::$pending = array(
454 + 'hit' => 0,
455 + 'miss' => 0,
456 + 'excluded' => 0,
457 + );
127 458
128 459 $hour = (int) ( time() - ( time() % 3600 ) );
129 460 $buf = self::buckets();
130 461 $last = end( $buf );
@@ -130,18 +461,21 @@
130 461 $last = end( $buf );
131 462 $updated = false;
132 463
133 464 if ( $last && $last['ts'] === $hour ) {
134 - $buf[ count( $buf ) - 1 ]['hits'] += $pending['hit'];
135 - $buf[ count( $buf ) - 1 ]['misses'] += $pending['miss'];
136 - $updated = true;
465 + $i = count( $buf ) - 1;
466 + $buf[ $i ]['hits'] += $pending['hit'];
467 + $buf[ $i ]['misses'] += $pending['miss'];
468 + $buf[ $i ]['excluded'] += $pending['excluded'];
469 + $updated = true;
137 470 }
138 471
139 472 if ( ! $updated ) {
140 473 $buf[] = array(
141 - 'ts' => $hour,
142 - 'hits' => $pending['hit'],
143 - 'misses' => $pending['miss'],
474 + 'ts' => $hour,
475 + 'hits' => $pending['hit'],
476 + 'misses' => $pending['miss'],
477 + 'excluded' => $pending['excluded'],
144 478 );
145 479 while ( count( $buf ) > self::MAX_BUCKETS ) {
146 480 array_shift( $buf );
147 481 }
@@ -146,7 +480,72 @@
146 480 array_shift( $buf );
147 481 }
148 482 }
149 483
150 - set_transient( self::TRANSIENT_KEY, $buf, self::TTL );
484 + self::write_buffer( $buf );
485 + self::bump_daily( $pending['hit'], $pending['miss'], $pending['excluded'] );
486 + }
487 +
488 + /**
489 + * Fold the just-flushed counts into the persistent daily series. The
490 + * hourly buckets expire after ~25h; this option is what makes 7/30-day
491 + * hit-ratio trends possible (issue #44). Autoload off — it's only read
492 + * by the dashboard/REST, never on the frontend hot path.
493 + */
494 + private static function bump_daily( int $hits, int $misses, int $excluded = 0 ): void {
495 + if ( $hits <= 0 && $misses <= 0 && $excluded <= 0 ) {
496 + return;
497 + }
498 + $day = gmdate( 'Y-m-d' );
499 + $series = get_option( self::DAILY_OPT, array() );
500 + if ( ! is_array( $series ) ) {
501 + $series = array();
502 + }
503 + if ( ! isset( $series[ $day ] ) || ! is_array( $series[ $day ] ) ) {
504 + $series[ $day ] = array(
505 + 'hits' => 0,
506 + 'misses' => 0,
507 + 'excluded' => 0,
508 + );
509 + }
510 + $series[ $day ]['hits'] += $hits;
511 + $series[ $day ]['misses'] += $misses;
512 + $series[ $day ]['excluded'] = (int) ( $series[ $day ]['excluded'] ?? 0 ) + $excluded;
513 + if ( count( $series ) > self::DAILY_MAX_DAYS ) {
514 + ksort( $series );
515 + $series = array_slice( $series, -self::DAILY_MAX_DAYS, null, true );
516 + }
517 + update_option( self::DAILY_OPT, $series, false );
518 + }
519 +
520 + /**
521 + * The stored daily hit/miss series, oldest→newest, at most $days rows.
522 + *
523 + * @return array<int,array{date:string,hits:int,misses:int,ratio:float}>
524 + */
525 + public static function daily_series( int $days = 30 ): array {
526 + $series = get_option( self::DAILY_OPT, array() );
527 + if ( ! is_array( $series ) || empty( $series ) ) {
528 + return array();
529 + }
530 + ksort( $series );
531 + $series = array_slice( $series, -max( 1, $days ), null, true );
532 + $out = array();
533 + foreach ( $series as $date => $row ) {
534 + if ( ! is_array( $row ) ) {
535 + continue;
536 + }
537 + $hits = (int) ( $row['hits'] ?? 0 );
538 + $misses = (int) ( $row['misses'] ?? 0 );
539 + $excluded = (int) ( $row['excluded'] ?? 0 );
540 + $total = $hits + $misses;
541 + $out[] = array(
542 + 'date' => (string) $date,
543 + 'hits' => $hits,
544 + 'misses' => $misses,
545 + 'excluded' => $excluded,
546 + 'ratio' => $total > 0 ? round( $hits / $total, 4 ) : 0.0,
547 + );
548 + }
549 + return $out;
151 550 }
152 551 }