PluginProbe
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN / 1.3.5
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN v1.3.5
1.3.5 1.3.4 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 All 31 releases
xspeed / includes / class-usage-tracker.php

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

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