# xspeed/1.0.3/includes/class-hit-counter.php

xSpeed Cache: AI-Powered Performance Hub with MCP, Caching &amp; CDN, version 1.0.3. 223 lines.

- Page: https://pluginprobe.com/plugins/xspeed/1.0.3/code/includes/class-hit-counter.php
- Raw: https://pluginprobe.com/plugins/xspeed/1.0.3/raw/includes/class-hit-counter.php
- Modified: 2026-06-09T06:20:14+00:00

Line numbers below start at 1. Link to a line or a range by appending a fragment to the
page URL, for example `https://pluginprobe.com/plugins/xspeed/1.0.3/code/includes/class-hit-counter.php#L10-L20`.

```php
<?php
/**
 * Hit_Counter — rolling 24h hits + misses for cache requests.
 *
 * Storage: one transient `xspeed_hit_buffer` containing a list of up to
 * 24 hourly buckets. Each bucket: [hour_start_ts, hits, misses]. Bucket
 * keyed by floor(time()/3600); old buckets drop off when we push a new
 * hour. Transient TTL set to 25 hours so an idle site doesn't lose its
 * history immediately after going quiet.
 *
 * Writes happen on every cached HIT and every MISS (Cache.php records
 * via the static record_* methods). We absorb the cost in an in-process
 * static accumulator that flushes to the transient once per request via
 * register_shutdown_function, so the served-from-disk hot path pays
 * nothing.
 *
 * @package XSpeed
 */

declare(strict_types=1);

namespace XSpeed;

defined( 'ABSPATH' ) || exit;

final class Hit_Counter {

	public const TRANSIENT_KEY = 'xspeed_hit_buffer';
	public const TTL           = 90000; // 25h
	public const MAX_BUCKETS   = 24;

	/**
	 * @var array<int,int> 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<int,array{ts:int,hits:int,misses:int}>
	 */
	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 );
	}
}

```
