PluginProbe
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN / 1.0.7
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN v1.0.7
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-usage-tracker.php

class-usage-tracker.php in xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN 1.0.7, at includes/class-usage-tracker.php

494 lines 16.5 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Usage_Tracker — anonymous, opt-in plugin usage analytics.
4 *
5 * Ported from the WP Insights SDK (the same engine WPDeveloper plugins such as
6 * EmbedPress ship). Trimmed for xSpeed: no deactivation "goodbye" survey, no
7 * email marketing capture by default.
8 *
9 * PRIVACY CONTRACT (see CLAUDE.md "Hard do-not" + readme.txt):
10 * Nothing is collected or sent until the site admin EXPLICITLY opts in via
11 * the setup wizard. `require_optin` is always true. Until `opt_in( true )`
12 * has run, `is_tracking_allowed()` is false, no cron is scheduled, and
13 * `do_tracking()` / `send_data()` short-circuit before any outbound HTTP.
14 *
15 * @package XSpeed
16 * @version 3.0.2 (WP Insights)
17 */
18
19 namespace XSpeed;
20
21 defined( 'ABSPATH' ) || exit;
22
23 use WP_Error;
24
25 if ( ! class_exists( __NAMESPACE__ . '\\Usage_Tracker' ) ) :
26
27 class Usage_Tracker {
28
29 /** WP Insights SDK version (kept for API compat with send.wpinsight.com). */
30 const WPINS_VERSION = '3.0.2';
31
32 /** Insights ingest endpoint. */
33 const API_URL = 'https://send.wpinsight.com/process-plugin-data';
34
35 /** Daily cron hook (only registered AFTER opt-in). */
36 const EVENT_HOOK = 'xspeed_do_weekly_action';
37
38 private $plugin_file = null;
39 private $plugin_name = null;
40
41 /** @var string */
42 public $recurrence = 'daily';
43
44 private $disabled_wp_cron;
45 private $require_optin;
46 private $marketing;
47 private $item_id;
48
49 /** @var Usage_Tracker|null */
50 private static $instance = null;
51
52 /**
53 * @param string $plugin_file Main plugin file (XSPEED_FILE).
54 * @param array $args opt_in, email_marketing, item_id.
55 */
56 public static function get_instance( $plugin_file, $args = array() ) {
57 if ( null === static::$instance ) {
58 static::$instance = new static( $plugin_file, $args );
59 }
60 return static::$instance;
61 }
62
63 public function __construct( $plugin_file, $args = array() ) {
64 $this->plugin_file = $plugin_file;
65 $this->plugin_name = basename( $this->plugin_file, '.php' );
66 $this->disabled_wp_cron = defined( 'DISABLE_WP_CRON' ) && true === DISABLE_WP_CRON;
67
68 // require_optin is intentionally forced true — never honor a caller
69 // that tries to disable consent gating.
70 $this->require_optin = true;
71 // Email marketing capture is OFF by default in xSpeed (EmbedPress
72 // defaults it on to send a discount coupon; we collect no email
73 // unless a caller explicitly turns it on).
74 $this->marketing = isset( $args['email_marketing'] ) ? (bool) $args['email_marketing'] : false;
75 $this->item_id = ! empty( $args['item_id'] ) ? $args['item_id'] : false;
76
77 register_deactivation_hook( $this->plugin_file, array( $this, 'deactivate_this_plugin' ) );
78 }
79
80 /**
81 * Hook the cron sender. Called once from Plugin::init(). Safe to call
82 * unconditionally: the cron event itself is only SCHEDULED after the
83 * user opts in, and do_tracking() re-checks consent before sending.
84 */
85 public function init() {
86 add_action( self::EVENT_HOOK, array( $this, 'do_tracking' ) );
87 }
88
89 /**
90 * Public opt-in / opt-out entry point. Called by the onboarding REST
91 * handler when the admin flips the wizard's consent toggle.
92 *
93 * @param bool $allow True = consent granted; false = revoked.
94 */
95 public function opt_in( $allow ) {
96 $this->set_is_tracking_allowed( (bool) $allow );
97 if ( $allow ) {
98 $this->schedule_tracking();
99 // Fire the first send immediately so the install is registered.
100 $this->do_tracking( true );
101 } else {
102 if ( ! $this->disabled_wp_cron ) {
103 wp_clear_scheduled_hook( self::EVENT_HOOK );
104 }
105 }
106 }
107
108 /** True only after an explicit opt-in. */
109 public function is_opted_in() {
110 return $this->is_tracking_allowed();
111 }
112
113 /**
114 * Schedule the daily send. Only ever called from opt_in( true ).
115 */
116 public function schedule_tracking() {
117 if ( $this->disabled_wp_cron ) {
118 return;
119 }
120 if ( ! wp_next_scheduled( self::EVENT_HOOK ) ) {
121 wp_schedule_event( time(), $this->recurrence, self::EVENT_HOOK );
122 }
123 }
124
125 /**
126 * On deactivation: tell insights we went inactive (only if opted in),
127 * then clear the cron. No reason survey is collected.
128 */
129 public function deactivate_this_plugin() {
130 if ( ! $this->is_tracking_allowed() ) {
131 return;
132 }
133 $body = $this->get_data();
134 $body['status'] = 'Deactivated';
135 $body['deactivated_date'] = time();
136 $this->send_data( $body );
137
138 if ( ! $this->disabled_wp_cron ) {
139 wp_clear_scheduled_hook( self::EVENT_HOOK );
140 }
141 }
142
143 /**
144 * Cron callback. Bails before any HTTP unless tracking is allowed and
145 * it's time to send.
146 *
147 * @param bool $force Skip the once-a-day throttle (used on first opt-in).
148 */
149 public function do_tracking( $force = false ) {
150 if ( empty( self::API_URL ) ) {
151 return;
152 }
153 if ( ! $this->is_tracking_allowed() ) {
154 return;
155 }
156 if ( ! $this->is_time_to_track() && ! $force ) {
157 return;
158 }
159 return $this->send_data( $this->get_data() );
160 }
161
162 /** Consent gate. */
163 private function is_tracking_allowed() {
164 $allow_tracking = get_option( 'wpins_allow_tracking' );
165 return is_array( $allow_tracking ) && isset( $allow_tracking[ $this->plugin_name ] );
166 }
167
168 /** Persist the consent flag in the shared WP Insights option. */
169 protected function set_is_tracking_allowed( $is_allowed ) {
170 $allow_tracking = get_option( 'wpins_allow_tracking' );
171 if ( ! is_array( $allow_tracking ) ) {
172 $allow_tracking = array();
173 }
174 if ( $is_allowed ) {
175 $allow_tracking[ $this->plugin_name ] = $this->plugin_name;
176 } else {
177 unset( $allow_tracking[ $this->plugin_name ] );
178 }
179 update_option( 'wpins_allow_tracking', $allow_tracking );
180 }
181
182 /** Once-a-day throttle. */
183 public function is_time_to_track() {
184 $track_times = get_option( 'wpins_last_track_time', array() );
185 if ( ! isset( $track_times[ $this->plugin_name ] ) ) {
186 return true;
187 }
188 return $track_times[ $this->plugin_name ] < strtotime( '-1 day' );
189 }
190
191 public function set_track_time() {
192 $track_times = get_option( 'wpins_last_track_time', array() );
193 $track_times[ $this->plugin_name ] = time();
194 update_option( 'wpins_last_track_time', $track_times );
195 }
196
197 /**
198 * Assemble the non-sensitive diagnostic payload. Documented verbatim in
199 * readme.txt — keep the two in sync if you add a field here.
200 */
201 public function get_data() {
202 $body = array(
203 'plugin_slug' => sanitize_text_field( $this->plugin_name ),
204 'url' => get_bloginfo( 'url' ),
205 'site_name' => get_bloginfo( 'name' ),
206 'site_version' => get_bloginfo( 'version' ),
207 'site_language' => get_bloginfo( 'language' ),
208 'charset' => get_bloginfo( 'charset' ),
209 'wpins_version' => self::WPINS_VERSION,
210 'php_version' => phpversion(),
211 'multisite' => is_multisite(),
212 );
213
214 if ( $this->marketing ) {
215 if ( ! function_exists( 'wp_get_current_user' ) ) {
216 include ABSPATH . 'wp-includes/pluggable.php';
217 }
218 $email = wp_get_current_user()->user_email;
219 if ( is_email( $email ) ) {
220 $body['email'] = $email;
221 }
222 }
223 $body['marketing_method'] = $this->marketing;
224 // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized,WordPress.Security.ValidatedSanitizedInput.MissingUnslash -- server software string, reported as-is to insights.
225 $body['server'] = isset( $_SERVER['SERVER_SOFTWARE'] ) ? $_SERVER['SERVER_SOFTWARE'] : '';
226
227 if ( ! function_exists( 'get_plugins' ) ) {
228 include ABSPATH . 'wp-admin/includes/plugin.php';
229 }
230 $plugins = array_keys( get_plugins() );
231 $active_plugins = is_network_admin()
232 ? array_keys( get_site_option( 'active_sitewide_plugins', array() ) )
233 : get_option( 'active_plugins', array() );
234 foreach ( $plugins as $key => $plugin ) {
235 if ( in_array( $plugin, $active_plugins, true ) ) {
236 unset( $plugins[ $key ] );
237 }
238 }
239 $body['active_plugins'] = $active_plugins;
240 $body['inactive_plugins'] = array_values( $plugins );
241 $body['text_direction'] = is_rtl() ? 'RTL' : 'LTR';
242
243 $plugin = $this->plugin_data();
244 if ( ! empty( $plugin ) ) {
245 if ( isset( $plugin['Name'] ) ) {
246 $body['plugin'] = sanitize_text_field( $plugin['Name'] );
247 }
248 if ( isset( $plugin['Version'] ) ) {
249 $body['version'] = sanitize_text_field( $plugin['Version'] );
250 }
251 $body['status'] = 'Active';
252 } else {
253 $body['status'] = 'NOT FOUND';
254 }
255
256 $theme = wp_get_theme();
257 if ( $theme->get( 'Name' ) ) {
258 $body['theme'] = sanitize_text_field( $theme->get( 'Name' ) );
259 }
260 if ( $theme->get( 'Version' ) ) {
261 $body['theme_version'] = sanitize_text_field( $theme->get( 'Version' ) );
262 }
263
264 // xSpeed's own configuration — which optimization features are
265 // enabled and their settings. Tells us what's actually used and
266 // where it breaks. Non-sensitive: these are feature flags +
267 // numeric/string knobs, never site content or personal data.
268 $config = $this->gather_config();
269 if ( ! empty( $config ) ) {
270 $body['xspeed_config'] = $config;
271 }
272
273 return $body;
274 }
275
276 /**
277 * Collect each registered module's stored settings, keyed by slug.
278 * Read through Settings_Manager so we get validated, schema-shaped
279 * values (feature toggles + knobs), not raw option blobs. Guarded so
280 * the tracker still works if the registry isn't booted yet.
281 *
282 * @return array<string,array>
283 */
284 private function gather_config() {
285 if ( ! class_exists( __NAMESPACE__ . '\\Module_Registry' )
286 || ! class_exists( __NAMESPACE__ . '\\Settings_Manager' ) ) {
287 return array();
288 }
289 $config = array();
290 foreach ( Module_Registry::all() as $slug => $module ) {
291 $scalars = $this->scalar_settings( Settings_Manager::get( (string) $slug ) );
292 if ( ! empty( $scalars ) ) {
293 $config[ (string) $slug ] = $scalars;
294 }
295 }
296 // Legacy fields still in xspeed_options (e.g. cache_enabled).
297 if ( class_exists( __NAMESPACE__ . '\\Settings' ) ) {
298 $legacy = $this->scalar_settings( Settings::get() );
299 if ( ! empty( $legacy ) ) {
300 $config['_options'] = $legacy;
301 }
302 }
303 return $config;
304 }
305
306 /**
307 * Key fragments that mark a credential / PII / identifying field. We do
308 * NOT report these at all — not the value, not even whether they're set.
309 * The goal is "which features are used", not "is a key configured", so
310 * anything secret-shaped is dropped outright. This is the guard that
311 * keeps API keys, tokens, passwords, license keys, emails, URLs, and
312 * brand assets out of the analytics payload entirely.
313 */
314 const SECRET_KEY_FRAGMENTS = array(
315 'key', 'token', 'secret', 'password', 'pass', 'license', 'auth',
316 'credential', 'email', 'url', 'endpoint', 'host', 'logo', 'prefix',
317 'zone', 'account', 'webhook', 'salt', 'nonce', 'name', 'credit',
318 );
319
320 /**
321 * Reduce a module's settings to just "which features are used + how
322 * they're tuned":
323 *
324 * - bool / int / float on a NON-sensitive key → sent as-is. These are
325 * the feature toggles and numeric knobs we actually want.
326 * - any key matching SECRET_KEY_FRAGMENTS → dropped entirely.
327 * - string values → dropped (free-text can hold secrets/PII, and a
328 * string isn't "feature usage" data anyway).
329 * - arrays (exclusion / cookie / query lists) → dropped.
330 *
331 * Net result: a compact map of feature flags + numeric settings, with
332 * zero credentials, URLs, names, or other identifying values.
333 *
334 * @param mixed $settings
335 * @return array
336 */
337 private function scalar_settings( $settings ) {
338 if ( ! is_array( $settings ) ) {
339 return array();
340 }
341 $out = array();
342 foreach ( $settings as $key => $value ) {
343 // Only booleans and numbers describe "feature usage"; strings
344 // and arrays are never feature-usage data, so skip them.
345 if ( ! is_bool( $value ) && ! is_int( $value ) && ! is_float( $value ) ) {
346 continue;
347 }
348 $lc = strtolower( (string) $key );
349 $is_secret = false;
350 foreach ( self::SECRET_KEY_FRAGMENTS as $frag ) {
351 if ( false !== strpos( $lc, $frag ) ) {
352 $is_secret = true;
353 break;
354 }
355 }
356 if ( $is_secret ) {
357 continue; // e.g. a numeric account id — drop it.
358 }
359 $out[ $key ] = $value;
360 }
361 return $out;
362 }
363
364 public function plugin_data() {
365 if ( ! function_exists( 'get_plugin_data' ) ) {
366 include ABSPATH . 'wp-admin/includes/plugin.php';
367 }
368 return get_plugin_data( $this->plugin_file );
369 }
370
371 /**
372 * Register the site with insights, then send diffs on subsequent runs.
373 * Mirrors the WP Insights site-id handshake so the server keeps a stable
374 * record per install.
375 */
376 public function send_data( $body ) {
377 $site_id_key = "wpins_{$this->plugin_name}_site_id";
378 $site_id = get_option( $site_id_key, false );
379 $site_url = get_bloginfo( 'url' );
380 $original_site_url = get_option( "wpins_{$this->plugin_name}_original_url", false );
381 $diff_data = array();
382 $failed_data = array();
383
384 if ( ( false === $original_site_url || $original_site_url !== $site_url )
385 && version_compare( $body['wpins_version'], '3.0.1', '>=' ) ) {
386 $site_id = false;
387 }
388
389 if ( false === $site_id && false !== $this->item_id ) {
390 $body['plugin_slug'] = $this->plugin_name;
391 $body['url'] = $site_url;
392 $body['item_id'] = $this->item_id;
393
394 $request = $this->remote_post( $body );
395 if ( ! is_wp_error( $request ) && 200 === $request['response']['code'] ) {
396 $retrieved_body = json_decode( wp_remote_retrieve_body( $request ), true );
397 if ( is_array( $retrieved_body ) && isset( $retrieved_body['siteId'] ) ) {
398 $site_id = $retrieved_body['siteId'];
399 update_option( $site_id_key, $site_id );
400 update_option( "wpins_{$this->plugin_name}_original_url", $site_url );
401 update_option( "wpins_{$this->plugin_name}_{$site_id}", $body );
402 }
403 } else {
404 $failed_data = $body;
405 }
406 }
407
408 $site_id_data_key = "wpins_{$this->plugin_name}_{$site_id}";
409 $site_id_data_failed_key = "wpins_{$this->plugin_name}_{$site_id}_send_failed";
410
411 if ( false !== $site_id ) {
412 $old_sent_data = get_option( $site_id_data_key, array() );
413 $diff_data = $this->diff( $body, $old_sent_data );
414 $failed_data = get_option( $site_id_data_failed_key, array() );
415 if ( ! empty( $failed_data ) && $diff_data !== $failed_data ) {
416 $failed_data = array_merge( $failed_data, $diff_data );
417 }
418 }
419
420 if ( ! empty( $failed_data ) && false !== $site_id ) {
421 $failed_data['plugin_slug'] = $this->plugin_name;
422 $failed_data['url'] = $site_url;
423 $failed_data['site_id'] = $site_id;
424 if ( false !== $original_site_url ) {
425 $failed_data['original_url'] = $original_site_url;
426 }
427 $request = $this->remote_post( $failed_data );
428 if ( ! is_wp_error( $request ) ) {
429 delete_option( $site_id_data_failed_key );
430 update_option( $site_id_data_key, array_merge( get_option( $site_id_data_key, array() ), $failed_data ) );
431 }
432 }
433
434 if ( ! empty( $diff_data ) && false !== $site_id && empty( $failed_data ) ) {
435 $diff_data['plugin_slug'] = $this->plugin_name;
436 $diff_data['url'] = $site_url;
437 $diff_data['site_id'] = $site_id;
438 if ( false !== $original_site_url ) {
439 $diff_data['original_url'] = $original_site_url;
440 }
441 $request = $this->remote_post( $diff_data );
442 if ( is_wp_error( $request ) ) {
443 update_option( $site_id_data_failed_key, $diff_data );
444 } else {
445 update_option( $site_id_data_key, array_merge( get_option( $site_id_data_key, array() ), $diff_data ) );
446 }
447 }
448
449 $this->set_track_time();
450
451 if ( isset( $request ) && is_wp_error( $request ) ) {
452 return $request;
453 }
454 return isset( $request );
455 }
456
457 protected function remote_post( $data = array(), $args = array() ) {
458 if ( empty( $data ) ) {
459 return;
460 }
461 $args = wp_parse_args(
462 $args,
463 array(
464 'method' => 'POST',
465 'timeout' => 30,
466 'redirection' => 5,
467 'httpversion' => '1.1',
468 'blocking' => true,
469 'body' => $data,
470 'user-agent' => 'XSpeed/' . ( defined( 'XSPEED_VERSION' ) ? XSPEED_VERSION : '1.0' ) . '; ' . get_bloginfo( 'url' ),
471 )
472 );
473 $request = wp_remote_post( esc_url_raw( self::API_URL ), $args );
474 if ( is_wp_error( $request )
475 || ( isset( $request['response']['code'] ) && 200 !== $request['response']['code'] ) ) {
476 return new WP_Error( 500, 'Something went wrong.' );
477 }
478 return $request;
479 }
480
481 protected function diff( $new_data, $old_data ) {
482 $data = array();
483 foreach ( (array) $new_data as $key => $value ) {
484 if ( isset( $old_data[ $key ] ) && $old_data[ $key ] === $value ) {
485 continue;
486 }
487 $data[ $key ] = $value;
488 }
489 return $data;
490 }
491 }
492
493 endif;
494