PluginProbe
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN / 1.3.0
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN v1.3.0
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 1.2.0 All 28 releases
xspeed / includes / class-cache-benchmark.php

class-cache-benchmark.php in xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN 1.3.0, at includes/class-cache-benchmark.php

263 lines 10.0 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Cache_Benchmark — fetches home_url() twice (with + without cache) and
4 * returns side-by-side TTFB / total-time / transfer-bytes timings.
5 *
6 * Powers the "before vs after cache" widget on the wizard's Done step
7 * and the Cache panel. Synthetic — measures local HTTP only, no real
8 * RUM. Good enough for a directional "cache helps by X%" number; the
9 * RUM module is what you want for real percentiles.
10 *
11 * Mechanics:
12 * - "Without cache": HTTP GET home_url() with header
13 * `X-XSpeed-Bypass: 1`. The advanced-cache drop-in honors this
14 * header and short-circuits, so WordPress fully renders.
15 * - "With cache": HTTP GET home_url() with no special header. The
16 * drop-in serves the cached file (cache HIT) when available.
17 * - First "with-cache" call may MISS if no entry yet — we run a
18 * warm-up hit first so the timed pair is HIT vs render.
19 *
20 * Each measurement records:
21 * ttfb_ms — curl_getinfo CURLINFO_STARTTRANSFER_TIME (or our
22 * own "time before body read" fallback).
23 * time_ms — total response time.
24 * bytes — DECODED payload size (what the browser parses).
25 * bytes_transferred — wire size: the request advertises
26 * `Accept-Encoding: gzip, deflate` with WP auto-
27 * decompression off, so this is what actually
28 * crossed the network (matches GTmetrix's
29 * "transferred" number, not the inflated one).
30 * status — HTTP code.
31 *
32 * @package XSpeed
33 */
34
35 declare(strict_types=1);
36
37 namespace XSpeed;
38
39 defined( 'ABSPATH' ) || exit;
40
41 final class Cache_Benchmark {
42
43 /** Benchmark run history (option, autoload off): newest LAST. */
44 public const HISTORY_OPT = 'xspeed_benchmark_history';
45
46 /** Runs retained — enough for a year of weekly runs with headroom. */
47 public const HISTORY_MAX = 100;
48
49 /**
50 * @return array{
51 * url:string,
52 * without_cache:array{ttfb_ms:float,time_ms:float,bytes:int,bytes_transferred:int,status:int},
53 * with_cache:array{ttfb_ms:float,time_ms:float,bytes:int,bytes_transferred:int,status:int,was_hit:bool},
54 * savings_pct:?float,
55 * savings_ms:?float,
56 * cache_enabled:bool,
57 * }
58 */
59 public static function run( ?string $url = null ): array {
60 if ( null === $url ) {
61 $url = home_url( '/' );
62 }
63 // Cache enablement lives on the legacy Settings option
64 // (`cache_enabled` boolean). Cache::is_enabled() doesn't
65 // exist — read through Settings::get() instead.
66 $cache_enabled = false;
67 if ( class_exists( '\\XSpeed\\Settings' ) ) {
68 $opts = Settings::get();
69 $cache_enabled = ! empty( $opts['cache_enabled'] );
70 }
71
72 // Warm up so the "with cache" timing reflects a HIT, not the
73 // initial generation cost.
74 if ( $cache_enabled ) {
75 self::measure( $url, false );
76 }
77
78 $without = self::measure( $url, true ); // bypass
79 $with = self::measure( $url, false ); // normal — should HIT
80
81 $savings_ms = null;
82 $savings_pct = null;
83 if ( $cache_enabled && $without['time_ms'] > 0 && $with['time_ms'] > 0 ) {
84 $diff = $without['time_ms'] - $with['time_ms'];
85 $savings_ms = max( 0.0, round( $diff, 1 ) );
86 $savings_pct = round( ( $diff / $without['time_ms'] ) * 100, 1 );
87 if ( $savings_pct < 0 ) {
88 $savings_pct = 0.0;
89 }
90 }
91
92 $result = array(
93 'url' => $url,
94 'without_cache' => $without,
95 'with_cache' => $with + array( 'was_hit' => $cache_enabled ),
96 'savings_pct' => $savings_pct,
97 'savings_ms' => $savings_ms,
98 'cache_enabled' => $cache_enabled,
99 );
100
101 self::record_run( $result );
102
103 return $result;
104 }
105
106 /**
107 * Persist a run into the history ring buffer (issue #43) so the
108 * dashboard can render a trend instead of a one-shot number. Failed
109 * fetches (status 0) are not recorded — a network blip isn't a data
110 * point about the site's performance.
111 *
112 * @param array $result The array shape run() returns.
113 */
114 public static function record_run( array $result ): void {
115 $without = isset( $result['without_cache'] ) && is_array( $result['without_cache'] ) ? $result['without_cache'] : array();
116 $with = isset( $result['with_cache'] ) && is_array( $result['with_cache'] ) ? $result['with_cache'] : array();
117 if ( (int) ( $without['status'] ?? 0 ) === 0 || (int) ( $with['status'] ?? 0 ) === 0 ) {
118 return;
119 }
120 $history = get_option( self::HISTORY_OPT, array() );
121 if ( ! is_array( $history ) ) {
122 $history = array();
123 }
124 $history[] = array(
125 'ts' => time(),
126 'uncached_ms' => (float) ( $without['time_ms'] ?? 0 ),
127 'cached_ms' => (float) ( $with['time_ms'] ?? 0 ),
128 'savings_ms' => isset( $result['savings_ms'] ) ? (float) $result['savings_ms'] : null,
129 'savings_pct' => isset( $result['savings_pct'] ) ? (float) $result['savings_pct'] : null,
130 'bytes' => (int) ( $with['bytes'] ?? 0 ),
131 'bytes_transferred' => (int) ( $with['bytes_transferred'] ?? 0 ),
132 'cache_enabled' => ! empty( $result['cache_enabled'] ),
133 );
134 if ( count( $history ) > self::HISTORY_MAX ) {
135 $history = array_slice( $history, -self::HISTORY_MAX );
136 }
137 update_option( self::HISTORY_OPT, $history, false );
138 }
139
140 /**
141 * Stored benchmark runs, oldest→newest, at most $limit rows.
142 *
143 * @return array<int,array<string,mixed>>
144 */
145 public static function history( int $limit = self::HISTORY_MAX ): array {
146 $history = get_option( self::HISTORY_OPT, array() );
147 if ( ! is_array( $history ) ) {
148 return array();
149 }
150 return array_slice( array_values( $history ), -max( 1, $limit ) );
151 }
152
153 /**
154 * Single timed request. wp_remote_get's `args` don't expose curl-
155 * level timing on every transport, so we wrap the call ourselves
156 * with microtime — close enough for a directional comparison.
157 *
158 * @return array{ttfb_ms:float,time_ms:float,bytes:int,bytes_transferred:int,status:int}
159 */
160 private static function measure( string $url, bool $bypass ): array {
161 $headers = array(
162 'User-Agent' => 'xSpeed Benchmark/1.0',
163 // Ask for compression like a real browser so the WIRE size is
164 // measurable. Only encodings we can decode locally — no brotli
165 // (ext-brotli is rare, and an undecodable body would break the
166 // uncompressed measurement).
167 'Accept-Encoding' => 'gzip, deflate',
168 );
169 if ( $bypass ) {
170 // The X-XSpeed-Bypass header only short-circuits the PHP
171 // drop-in. When nginx static-rewrite is firing it would
172 // still serve the cached file directly, so the "without
173 // cache" measurement would look identical to "with cache"
174 // (the bug visible on the dashboard's benchmark widget).
175 // Append a cache-buster query string so the nginx
176 // snippet's `if ($args)` check bails too, forcing the
177 // request all the way through to PHP.
178 $headers['X-XSpeed-Bypass'] = '1';
179 $bust_url = $url . ( false === strpos( $url, '?' ) ? '?' : '&' )
180 . 'xspeed_bypass=' . wp_generate_password( 12, false, false );
181 $url = $bust_url;
182 }
183 $start = microtime( true );
184 $res = wp_remote_get(
185 $url,
186 array(
187 'timeout' => 10,
188 'headers' => $headers,
189 // Disable WP's internal caching layer — every call MUST hit the network.
190 'reject_unsafe_urls' => false,
191 'sslverify' => false, // self-signed sandboxes
192 // Keep the body EXACTLY as it came off the wire. WP's
193 // transport otherwise auto-decompresses AND strips the
194 // Content-Encoding header, which is how the old code ended
195 // up reporting ~180KB for a page that transfers 35KB.
196 'decompress' => false,
197 )
198 );
199 $elapsed = ( microtime( true ) - $start ) * 1000.0;
200
201 if ( is_wp_error( $res ) ) {
202 return array(
203 'ttfb_ms' => 0.0,
204 'time_ms' => round( $elapsed, 1 ),
205 'bytes' => 0,
206 'bytes_transferred' => 0,
207 'status' => 0,
208 );
209 }
210 $status = (int) wp_remote_retrieve_response_code( $res );
211 $body = wp_remote_retrieve_body( $res );
212 $raw = is_string( $body ) ? $body : '';
213
214 // Wire size vs decoded size. `bytes` keeps its historical meaning
215 // (uncompressed payload) so existing consumers don't shift; the new
216 // `bytes_transferred` is what actually crossed the network.
217 $encoding = (string) wp_remote_retrieve_header( $res, 'content-encoding' );
218 $decoded = self::decode_body( $raw, $encoding );
219
220 // wp_remote_get doesn't surface TTFB separately on the default
221 // transport. We surface total time as both fields and let the
222 // widget pick a sensible display.
223 return array(
224 'ttfb_ms' => round( $elapsed, 1 ),
225 'time_ms' => round( $elapsed, 1 ),
226 'bytes' => strlen( null !== $decoded ? $decoded : $raw ),
227 'bytes_transferred' => strlen( $raw ),
228 'status' => $status,
229 );
230 }
231
232 /**
233 * Decode a response body per its Content-Encoding. Pure — unit-tested.
234 *
235 * @param string $body Raw (possibly compressed) body.
236 * @param string $encoding Content-Encoding header value ('' when none).
237 * @return string|null Decoded body, the input when identity/none, or
238 * null when the encoding is unknown/undecodable.
239 */
240 public static function decode_body( string $body, string $encoding ): ?string {
241 $encoding = strtolower( trim( $encoding ) );
242 if ( '' === $encoding || 'identity' === $encoding ) {
243 return $body;
244 }
245 if ( '' === $body ) {
246 return $body;
247 }
248 if ( false !== strpos( $encoding, 'gzip' ) && function_exists( 'gzdecode' ) ) {
249 $out = @gzdecode( $body ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- corrupt stream must degrade to null, not warn.
250 return is_string( $out ) ? $out : null;
251 }
252 if ( false !== strpos( $encoding, 'deflate' ) && function_exists( 'gzinflate' ) ) {
253 // Some servers send zlib-wrapped deflate; try raw first, then zlib.
254 $out = @gzinflate( $body ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- see above.
255 if ( ! is_string( $out ) && function_exists( 'gzuncompress' ) ) {
256 $out = @gzuncompress( $body ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- see above.
257 }
258 return is_string( $out ) ? $out : null;
259 }
260 return null;
261 }
262 }
263