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.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 1.2.1 All 27 releases
xspeed / includes / class-usage-tracker.php

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

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