Pending increments keyed by metric ('hit'|'miss'). * Flushed to the transient on shutdown. */ private static $pending = array( 'hit' => 0, 'miss' => 0 ); /** * @var bool Whether the shutdown flush is already registered. */ private static $shutdown_registered = false; public static function record_hit(): void { ++self::$pending['hit']; self::ensure_shutdown_flush(); } public static function record_miss(): void { ++self::$pending['miss']; self::ensure_shutdown_flush(); } /** * Add `$count` HITs in one shot. Used by collect_nginx_log_hits() * to attribute many HITs served directly by nginx (bypassing PHP) * to the counter once we've drained the log file. */ public static function record_hits_batch( int $count ): void { if ( $count <= 0 ) { return; } self::$pending['hit'] += $count; self::ensure_shutdown_flush(); } /** * Drain the nginx HITs log file written by the server-level rewrite * block (see Cache::nginx_snippet()). Each cache HIT served directly * by nginx appends one line to wp-content/cache/xspeed/hits.log; * this method reads the line count, truncates the file, and folds * the count into Hit_Counter via record_hits_batch. * * Returns the number of HITs collected (0 if the log is missing, * empty, or the rewrite block isn't engaged). * * Concurrency: file is opened with LOCK_EX before the read/truncate * round-trip so a concurrent nginx write can't lose entries. Nginx * uses buffer=16k flush=10s on its access_log so writes are batched * and the lock contention is negligible. */ public static function collect_nginx_log_hits(): int { $path = WP_CONTENT_DIR . '/cache/xspeed/hits.log'; if ( ! file_exists( $path ) ) { return 0; } if ( filesize( $path ) === 0 ) { return 0; } // 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. $fp = @fopen( $path, 'r+' ); if ( ! $fp ) { return 0; } // Non-blocking exclusive lock — if nginx is mid-write we just skip // this collection and try again on the next dashboard load. // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_flock -- See fopen rationale. if ( ! @flock( $fp, LOCK_EX | LOCK_NB ) ) { // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged fclose( $fp ); return 0; } $count = 0; while ( ( $line = fgets( $fp ) ) !== false ) { if ( '' !== rtrim( $line ) ) { ++$count; } } // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_ftruncate -- See fopen rationale. ftruncate( $fp, 0 ); flock( $fp, LOCK_UN ); fclose( $fp ); if ( $count > 0 ) { self::record_hits_batch( $count ); // Flush immediately — the next read of totals_24h() happens // inline in Cache::get_stats(), before register_shutdown_function // could fire. Without this, the dashboard sees stale numbers // and the just-drained HITs appear on the FOLLOWING refresh. self::flush_pending(); } return $count; } /** * Returns up to MAX_BUCKETS most-recent hourly buckets oldest → * newest. Each bucket: [ts => unix hour-start, hits => int, misses * => int ]. * * @return array */ public static function buckets(): array { $buf = get_transient( self::TRANSIENT_KEY ); if ( ! is_array( $buf ) ) { return array(); } // Defensive — strip anything not shaped right. $out = array(); foreach ( $buf as $b ) { if ( is_array( $b ) && isset( $b['ts'], $b['hits'], $b['misses'] ) ) { $out[] = array( 'ts' => (int) $b['ts'], 'hits' => (int) $b['hits'], 'misses' => (int) $b['misses'], ); } } return $out; } /** * Totals over the last 24h (sum across all buckets). * * @return array{hits:int,misses:int,ratio:float} */ public static function totals_24h(): array { $buckets = self::buckets(); $hits = 0; $misses = 0; foreach ( $buckets as $b ) { $hits += $b['hits']; $misses += $b['misses']; } $total = $hits + $misses; return array( 'hits' => $hits, 'misses' => $misses, 'ratio' => $total > 0 ? round( $hits / $total, 4 ) : 0.0, ); } public static function reset(): void { delete_transient( self::TRANSIENT_KEY ); self::$pending = array( 'hit' => 0, 'miss' => 0 ); } /** * One-shot register on first record_* call this request. */ private static function ensure_shutdown_flush(): void { if ( self::$shutdown_registered ) { return; } self::$shutdown_registered = true; register_shutdown_function( array( __CLASS__, 'flush_pending' ) ); } /** * Flush in-process counters into the transient. Bucketed by current * hour. New hour → append a bucket and drop the oldest if we exceed * MAX_BUCKETS. */ public static function flush_pending(): void { $pending = self::$pending; if ( 0 === $pending['hit'] && 0 === $pending['miss'] ) { return; } self::$pending = array( 'hit' => 0, 'miss' => 0 ); $hour = (int) ( time() - ( time() % 3600 ) ); $buf = self::buckets(); $last = end( $buf ); $updated = false; if ( $last && $last['ts'] === $hour ) { $buf[ count( $buf ) - 1 ]['hits'] += $pending['hit']; $buf[ count( $buf ) - 1 ]['misses'] += $pending['miss']; $updated = true; } if ( ! $updated ) { $buf[] = array( 'ts' => $hour, 'hits' => $pending['hit'], 'misses' => $pending['miss'], ); while ( count( $buf ) > self::MAX_BUCKETS ) { array_shift( $buf ); } } set_transient( self::TRANSIENT_KEY, $buf, self::TTL ); } }