| @@ -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 | } |