PluginProbe
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN / trunk
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN vtrunk
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 1.2.2 1.2.3
xspeed / includes / class-usage-tracker.php

class-usage-tracker.php in xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN trunk, 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