PluginProbe
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN / 1.2.0
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN v1.2.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-pro-audit.php

class-pro-audit.php in xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN 1.2.0, at includes/class-pro-audit.php

252 lines 9.5 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Pro_Audit — scans the current Free configuration + cache stats and
4 * surfaces Pro features that would specifically help THIS site.
5 *
6 * Powers the dashboard's "Run Pro audit" button. The point isn't to
7 * list every Pro feature; it's to make each suggestion personal
8 * ("Cache hit ratio is 38% → Pro Recommendations would tell you why")
9 * so the user converts because Pro solves a problem they actually
10 * see, not because we shouted "BUY NOW."
11 *
12 * Pure read-only. Returns an ordered list of suggestions:
13 *
14 * [ id, severity ('high'|'med'|'low'), reason, fact ]
15 *
16 * - id → matches a key in PRO_FEATURES (the React catalog),
17 * so the panel renders title/body without duplicating
18 * copy here.
19 * - severity controls sort order + visual tone.
20 * - reason → one-sentence explanation specific to this site's
21 * state. Already-baked numbers/percentages so the
22 * React side just prints it.
23 * - fact → optional shorter inline stat (e.g. "38%") for the
24 * result card's chip.
25 *
26 * Adding a rule: drop another `if (…) $out[] = …` block in run().
27 * Rules are independent — keep them small + concrete + factual.
28 *
29 * @package XSpeed
30 */
31
32 declare(strict_types=1);
33
34 namespace XSpeed;
35
36 defined( 'ABSPATH' ) || exit;
37
38 final class Pro_Audit {
39
40 public const SEVERITY_HIGH = 'high';
41 public const SEVERITY_MED = 'med';
42 public const SEVERITY_LOW = 'low';
43
44 /**
45 * Snapshot of state every rule needs. Computed once per audit run
46 * so we don't read the same option 8 times.
47 *
48 * @param array|null $totals_override Test injection — Brain Monkey
49 * can't mock static class methods,
50 * so tests synthesize the 24h
51 * counter shape here directly.
52 * Production callers leave null.
53 *
54 * @return array<string,mixed>
55 */
56 private static function snapshot( ?array $totals_override = null ): array {
57 $opts = static function ( string $slug ): array {
58 return (array) get_option( 'xspeed_module_' . $slug, array() );
59 };
60 if ( null !== $totals_override ) {
61 $totals = $totals_override;
62 } elseif ( class_exists( '\\XSpeed\\Hit_Counter' ) ) {
63 $totals = Hit_Counter::totals_24h();
64 } else {
65 $totals = array( 'hits' => 0, 'misses' => 0, 'excluded' => 0, 'ratio' => 0.0 );
66 }
67 $cloudflare = $opts( 'cloudflare' );
68 return array(
69 'cache' => $opts( 'cache' ),
70 'minify' => $opts( 'minify' ),
71 'lazy' => $opts( 'lazy' ),
72 'gzip' => $opts( 'gzip' ),
73 'browser_cache' => $opts( 'browser-cache' ),
74 'cloudflare' => $cloudflare,
75 'cdn' => $opts( 'cdn' ),
76 'database' => $opts( 'database' ),
77 'preloader' => $opts( 'preloader' ),
78 'heartbeat' => $opts( 'heartbeat' ),
79 'cache_enabled' => class_exists( '\\XSpeed\\Settings' )
80 ? ! empty( Settings::get()['cache_enabled'] )
81 : false,
82 // An edge cache (Cloudflare) in front means the origin hit ratio is
83 // only the origin layer — hits served at the edge never reach PHP —
84 // so a low number is an attribution artefact, not a cache problem.
85 // Rule 2 must not fire an upsell off it. (#118)
86 'edge_cache' => ! empty( $cloudflare['enabled'] ),
87 'totals_24h' => array(
88 'hits' => (int) ( $totals['hits'] ?? 0 ),
89 'misses' => (int) ( $totals['misses'] ?? 0 ),
90 // 404s + bots, kept out of the ratio denominator. (#118)
91 'excluded' => (int) ( $totals['excluded'] ?? 0 ),
92 'total' => (int) ( $totals['hits'] ?? 0 ) + (int) ( $totals['misses'] ?? 0 ),
93 'ratio' => (float) ( $totals['ratio'] ?? 0.0 ),
94 ),
95 );
96 }
97
98 /**
99 * @param array|null $totals_override See snapshot(). Production
100 * callers pass nothing.
101 *
102 * @return array<int,array{id:string,severity:string,reason:string,fact?:string}>
103 */
104 public static function run( ?array $totals_override = null ): array {
105 $s = self::snapshot( $totals_override );
106 $out = array();
107
108 // Rule 1 — Cloudflare connected but APO not in use.
109 // High signal: user already pays the Cloudflare overhead, APO
110 // is the highest-leverage Pro feature they can flip on next.
111 if ( ! empty( $s['cloudflare']['enabled'] ) ) {
112 $out[] = array(
113 'id' => 'cloudflare-apo',
114 'severity' => self::SEVERITY_HIGH,
115 'reason' => 'Cloudflare is already connected. Pro adds Automatic Platform Optimization, which edge-caches your HTML — typically cuts TTFB in half.',
116 'fact' => 'Cloudflare on',
117 );
118 }
119
120 // Rule 2 — Low cache hit ratio with meaningful traffic.
121 // "Meaningful" = > 50 hits over 24h; below that the ratio is
122 // statistical noise and we'd suggest based on bad data. The ratio is
123 // now computed over real traffic only (404s + bots excluded, #118), and
124 // we skip it entirely when an edge cache fronts the origin — behind
125 // Cloudflare a low origin ratio means hits are served at the edge, not
126 // that the cache is failing, so firing a "your cache is bad" upsell off
127 // it is selling against a measurement artefact.
128 if ( empty( $s['edge_cache'] )
129 && $s['totals_24h']['total'] >= 50
130 && $s['totals_24h']['ratio'] < 0.5 ) {
131 $pct = (int) round( $s['totals_24h']['ratio'] * 100 );
132 $out[] = array(
133 'id' => 'recommendations',
134 'severity' => self::SEVERITY_HIGH,
135 'reason' => sprintf(
136 'Cache hit ratio is %d%% over the last 24h. Pro Recommendations identifies which URLs miss the cache and why, with one-click fixes.',
137 $pct
138 ),
139 'fact' => $pct . '% hit',
140 );
141 }
142
143 // Rule 3 — Lazy-load enabled but no auto WebP/AVIF.
144 // User cares about images (lazy on) → next gain is format.
145 if ( ! empty( $s['lazy']['lazy_images'] ) ) {
146 $out[] = array(
147 'id' => 'webp-avif',
148 'severity' => self::SEVERITY_MED,
149 'reason' => 'Images are lazy-loaded. Pro auto-converts new JPEG/PNG uploads to WebP and AVIF — typically 25-35% smaller at the same visual quality.',
150 );
151 }
152
153 // Rule 4 — High traffic without RUM data.
154 // Real-user metrics matter more than synthetic Lighthouse when
155 // the site has actual visitors.
156 if ( $s['totals_24h']['total'] >= 100 ) {
157 $views = number_format( $s['totals_24h']['total'] );
158 $out[] = array(
159 'id' => 'rum',
160 'severity' => self::SEVERITY_MED,
161 'reason' => sprintf(
162 'You served %s requests in 24h. Pro RUM samples actual LCP, CLS and INP from those visitors — Lighthouse only simulates one device, one connection.',
163 $views
164 ),
165 'fact' => $views . ' / 24h',
166 );
167 }
168
169 // Rule 5 — HTML minify on but JS minify off (theme-safe stance).
170 // Suggest Critical CSS as the next gain that doesn't touch JS.
171 if ( ! empty( $s['minify']['minify_html'] ) && empty( $s['minify']['minify_js'] ) ) {
172 $out[] = array(
173 'id' => 'critical-css',
174 'severity' => self::SEVERITY_MED,
175 'reason' => 'JS minify is off (good — high theme-conflict risk). Pro Critical CSS delivers similar first-paint gains without touching JavaScript.',
176 );
177 }
178
179 // Rule 6 — Database cleanup on manual schedule.
180 // Only fire when the user has actually configured the Database
181 // module (has saved options). Empty option = user hasn't
182 // touched it; don't suggest scheduling something they might
183 // never use.
184 if ( ! empty( $s['database'] ) && 'manual' === ( $s['database']['schedule'] ?? 'manual' ) ) {
185 $out[] = array(
186 'id' => 'recommendations',
187 'severity' => self::SEVERITY_LOW,
188 'reason' => 'Database cleanup is set to manual. Pro Recommendations engine auto-schedules cleanups based on smart triggers (after publish, before backup).',
189 );
190 }
191
192 // Rule 7 — Agency / professional usage signal.
193 // >= 5 enabled modules suggests serious use → white-label is
194 // what they'd actually want next.
195 $enabled = 0;
196 foreach ( array( 'minify', 'gzip', 'lazy', 'browser_cache', 'cloudflare', 'cdn', 'preloader' ) as $k ) {
197 if ( ! empty( $s[ $k ]['enabled'] ) ) {
198 $enabled++;
199 }
200 }
201 if ( $s['cache_enabled'] ) {
202 $enabled++;
203 }
204 if ( $enabled >= 5 ) {
205 $out[] = array(
206 'id' => 'white-label',
207 'severity' => self::SEVERITY_LOW,
208 'reason' => sprintf(
209 'You\'ve configured %d modules — looks like agency work. Pro White-Label rebrands the dashboard chrome for client handoff.',
210 $enabled
211 ),
212 'fact' => $enabled . ' modules',
213 );
214 }
215
216 // Fallback — never return an empty audit. Analytics is the
217 // safe always-relevant suggestion (every site has cache
218 // activity to chart).
219 if ( empty( $out ) ) {
220 $out[] = array(
221 'id' => 'analytics',
222 'severity' => self::SEVERITY_LOW,
223 'reason' => 'See which pages benefit most from caching, where your slow URLs are, and your hit-ratio over time.',
224 );
225 }
226
227 // Dedupe by id, keeping the highest-severity rule per feature.
228 // Rules independently suggest the same feature for different
229 // reasons; pick the strongest reason to show.
230 $by_id = array();
231 $order = array( self::SEVERITY_HIGH => 0, self::SEVERITY_MED => 1, self::SEVERITY_LOW => 2 );
232 foreach ( $out as $row ) {
233 $id = $row['id'];
234 if ( ! isset( $by_id[ $id ] ) ) {
235 $by_id[ $id ] = $row;
236 continue;
237 }
238 $existing_rank = $order[ $by_id[ $id ]['severity'] ] ?? 9;
239 $new_rank = $order[ $row['severity'] ] ?? 9;
240 if ( $new_rank < $existing_rank ) {
241 $by_id[ $id ] = $row;
242 }
243 }
244
245 $out = array_values( $by_id );
246 usort( $out, static function ( $a, $b ) use ( $order ) {
247 return ( $order[ $a['severity'] ] ?? 9 ) <=> ( $order[ $b['severity'] ] ?? 9 );
248 } );
249 return $out;
250 }
251 }
252