PluginProbe
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN / 1.3.3
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN v1.3.3
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
← All changes | includes/class-cache-benchmark.php +130 -19 1.0.31.3.3 View file →
@@ -17,13 +17,18 @@
17 17 * - First "with-cache" call may MISS if no entry yet — we run a
18 18 * warm-up hit first so the timed pair is HIT vs render.
19 19 *
20 20 * Each measurement records:
21 - * ttfb_ms — curl_getinfo CURLINFO_STARTTRANSFER_TIME (or our own
22 - * "time before body read" fallback).
23 - * time_ms — total response time.
24 - * bytes — Content-Length or strlen(body).
25 - * status — HTTP code.
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.
26 31 *
27 32 * @package XSpeed
28 33 */
29 34
@@ -34,13 +39,19 @@
34 39 defined( 'ABSPATH' ) || exit;
35 40
36 41 final class Cache_Benchmark {
37 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 +
38 49 /**
39 50 * @return array{
40 51 * url:string,
41 - * without_cache:array{ttfb_ms:float,time_ms:float,bytes:int,status:int},
42 - * with_cache:array{ttfb_ms:float,time_ms:float,bytes:int,status:int,was_hit:bool},
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},
43 54 * savings_pct:?float,
44 55 * savings_ms:?float,
45 56 * cache_enabled:bool,
46 57 * }
@@ -77,9 +88,9 @@
77 88 $savings_pct = 0.0;
78 89 }
79 90 }
80 91
81 - return array(
92 + $result = array(
82 93 'url' => $url,
83 94 'without_cache' => $without,
84 95 'with_cache' => $with + array( 'was_hit' => $cache_enabled ),
85 96 'savings_pct' => $savings_pct,
@@ -85,20 +96,76 @@
85 96 'savings_pct' => $savings_pct,
86 97 'savings_ms' => $savings_ms,
87 98 'cache_enabled' => $cache_enabled,
88 99 );
100 +
101 + self::record_run( $result );
102 +
103 + return $result;
89 104 }
90 105
91 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 + /**
92 154 * Single timed request. wp_remote_get's `args` don't expose curl-
93 155 * level timing on every transport, so we wrap the call ourselves
94 156 * with microtime — close enough for a directional comparison.
95 157 *
96 - * @return array{ttfb_ms:float,time_ms:float,bytes:int,status:int}
158 + * @return array{ttfb_ms:float,time_ms:float,bytes:int,bytes_transferred:int,status:int}
97 159 */
98 160 private static function measure( string $url, bool $bypass ): array {
99 161 $headers = array(
100 - 'User-Agent' => 'xSpeed Benchmark/1.0',
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',
101 168 );
102 169 if ( $bypass ) {
103 170 // The X-XSpeed-Bypass header only short-circuits the PHP
104 171 // drop-in. When nginx static-rewrite is firing it would
@@ -121,8 +188,13 @@
121 188 'headers' => $headers,
122 189 // Disable WP's internal caching layer — every call MUST hit the network.
123 190 'reject_unsafe_urls' => false,
124 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,
125 197 )
126 198 );
127 199 $elapsed = ( microtime( true ) - $start ) * 1000.0;
128 200
@@ -127,25 +199,64 @@
127 199 $elapsed = ( microtime( true ) - $start ) * 1000.0;
128 200
129 201 if ( is_wp_error( $res ) ) {
130 202 return array(
131 - 'ttfb_ms' => 0.0,
132 - 'time_ms' => round( $elapsed, 1 ),
133 - 'bytes' => 0,
134 - 'status' => 0,
203 + 'ttfb_ms' => 0.0,
204 + 'time_ms' => round( $elapsed, 1 ),
205 + 'bytes' => 0,
206 + 'bytes_transferred' => 0,
207 + 'status' => 0,
135 208 );
136 209 }
137 210 $status = (int) wp_remote_retrieve_response_code( $res );
138 211 $body = wp_remote_retrieve_body( $res );
139 - $bytes = is_string( $body ) ? strlen( $body ) : 0;
212 + $raw = is_string( $body ) ? $body : '';
140 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 +
141 220 // wp_remote_get doesn't surface TTFB separately on the default
142 221 // transport. We surface total time as both fields and let the
143 222 // widget pick a sensible display.
144 223 return array(
145 - 'ttfb_ms' => round( $elapsed, 1 ),
146 - 'time_ms' => round( $elapsed, 1 ),
147 - 'bytes' => $bytes,
148 - 'status' => $status,
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,
149 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;
150 261 }
151 262 }