PluginProbe
Jetpack – WP Security, Backup, Speed, & Growth / 16.3-a.3
Jetpack – WP Security, Backup, Speed, & Growth v16.3-a.3
16.3-a.5 16.3-a.7 16.3-a.3 16.3-a.1 16.2 16.2-beta 12.0.3 12.1.3 12.2.3 12.3.2 12.4.2 12.5.2 12.6.4 12.7.3 12.8.3 12.9.5 13.0.2 13.1.5 13.2.4 13.3.3 13.4.5 13.5.2 13.6.2 13.7.2 13.8.3 All 506 releases
jetpack / jetpack_vendor / automattic / woocommerce-analytics / src / class-wc-analytics-tracking.php

class-wc-analytics-tracking.php in Jetpack – WP Security, Backup, Speed, & Growth 16.3-a.3, at jetpack_vendor/automattic/woocommerce-analytics/src/class-wc-analytics-tracking.php

655 lines 19.7 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * WooCommerce Analytics Tracking for tracking frontend events
4 *
5 * This class is designed to work without WooCommerce dependencies,
6 * enabling it to run at the MU-plugin stage without loading WooCommerce to optimize performance.
7 *
8 * @package automattic/woocommerce-analytics
9 */
10
11 namespace Automattic\Woocommerce_Analytics;
12
13 use Automattic\Jetpack\Device_Detection;
14 use Automattic\Jetpack\Device_Detection\User_Agent_Info;
15 use WP_Error;
16
17 /**
18 * WooCommerce Analytics Tracking class
19 */
20 class WC_Analytics_Tracking {
21 /**
22 * Event prefix.
23 *
24 * @var string
25 */
26 const PREFIX = 'woocommerceanalytics_';
27
28 /**
29 * Option name for storing daily salt data.
30 *
31 * @var string
32 */
33 const DAILY_SALT_OPTION = 'woocommerce_analytics_daily_salt';
34
35 /**
36 * Event queue.
37 *
38 * @var array
39 */
40 protected static $event_queue = array();
41
42 /**
43 * Batch pixel queue for batched requests.
44 *
45 * @var array
46 */
47 private static $pixel_batch_queue = array();
48
49 /**
50 * Whether the shutdown hook has been registered.
51 *
52 * @var bool
53 */
54 private static $shutdown_hook_registered = false;
55
56 /**
57 * Cached user IP address for the current request.
58 *
59 * @var string|null
60 */
61 private static $cached_ip = null;
62
63 /**
64 * Cached visitor ID for the current request.
65 *
66 * @var string|null
67 */
68 private static $cached_visitor_id = null;
69
70 /**
71 * Record an event in Tracks and ClickHouse (If enabled).
72 *
73 * @param string $event_name The name of the event.
74 * @param array $event_properties Custom properties to send with the event.
75 *
76 * @return bool|WP_Error True on emit or deliberate skip (no consent, bot UA,
77 * or cookie-less context); WP_Error if pixel firing failed.
78 */
79 public static function record_event( $event_name, $event_properties = array() ) {
80 // Check consent before recording any event.
81 if ( ! Consent_Manager::has_analytics_consent() ) {
82 return true; // Skip recording.
83 }
84
85 // Skip recording if the request is coming from a bot.
86 if ( User_Agent_Info::is_bot() ) {
87 return true;
88 }
89
90 // Skip events that arrive without a stable visitor id (e.g. no tk_ai cookie); see get_visitor_id().
91 if ( empty( self::get_visitor_id() ) ) {
92 return true;
93 }
94
95 $prefixed_event_name = self::PREFIX . $event_name;
96 $properties = self::get_properties( $prefixed_event_name, $event_properties );
97
98 // Record Tracks event.
99 $tracks_error = null;
100 $tracks_result = self::record_tracks_event( $properties );
101 if ( is_wp_error( $tracks_result ) ) {
102 $tracks_error = $tracks_result;
103 }
104
105 // Record ClickHouse event, if applicable.
106 $ch_error = null;
107 if ( Features::is_clickhouse_enabled() || ( isset( $properties['ch'] ) && 1 === (int) $properties['ch'] ) ) {
108 $properties['ch'] = 1;
109 $ch_result = self::record_ch_event( $properties );
110 if ( is_wp_error( $ch_result ) ) {
111 $ch_error = $ch_result;
112 }
113 }
114
115 // If both failed, return the Tracks error (primary), else the CH error, else true.
116 if ( $tracks_error ) {
117 return $tracks_error;
118 }
119 if ( $ch_error ) {
120 return $ch_error;
121 }
122
123 return true;
124 }
125
126 /**
127 * Queue an event in the event queue which will be processed on the page load in client-side analytics.
128 *
129 * @param string $event_name The name of the event.
130 * @param array $properties The event properties.
131 */
132 public static function add_event_to_queue( $event_name, $properties = array() ) {
133 self::$event_queue[] = array(
134 'eventName' => $event_name,
135 'props' => $properties,
136 );
137 }
138
139 /**
140 * Get the event queue.
141 *
142 * @return array The event queue.
143 */
144 public static function get_event_queue() {
145 return self::$event_queue;
146 }
147
148 /**
149 * Record an event in Tracks.
150 *
151 * @param array $properties Properties to send with the event.
152 * @return bool|WP_Error True for success or WP_Error if the event pixel could not be fired.
153 */
154 private static function record_tracks_event( $properties = array() ) {
155 $pixel_url = Pixel_Builder::build_tracks_url( $properties );
156
157 if ( is_wp_error( $pixel_url ) ) {
158 return $pixel_url;
159 }
160
161 return self::record_pixel_url( $pixel_url );
162 }
163
164 /**
165 * Record a ClickHouse event.
166 *
167 * @param array $properties The event properties.
168 * @return bool|WP_Error True for success or WP_Error if the event pixel could not be fired.
169 */
170 private static function record_ch_event( $properties ) {
171 $pixel_url = Pixel_Builder::build_ch_url( $properties );
172
173 if ( is_wp_error( $pixel_url ) ) {
174 return $pixel_url;
175 }
176
177 return self::record_pixel_url( $pixel_url );
178 }
179
180 /**
181 * Record a pixel URL using batching.
182 *
183 * @param string $pixel_url The pixel URL to record.
184 * @return bool|WP_Error True for success or WP_Error if the event pixel could not be fired.
185 */
186 private static function record_pixel_url( $pixel_url ) {
187 if ( empty( $pixel_url ) ) {
188 return new WP_Error( 'invalid_pixel', 'cannot generate tracks pixel for given input', 400 );
189 }
190
191 // Check if batching is supported.
192 $can_batch = ( class_exists( 'WpOrg\Requests\Requests' ) && method_exists( 'WpOrg\Requests\Requests', 'request_multiple' ) )
193 || ( class_exists( 'Requests' ) && method_exists( 'Requests', 'request_multiple' ) );
194
195 if ( $can_batch ) {
196 // Queue the pixel and send on shutdown.
197 self::queue_pixel_for_batch( $pixel_url );
198 } else {
199 // Send immediately as batching is not supported.
200 Pixel_Builder::send_pixel( $pixel_url );
201 }
202
203 return true;
204 }
205
206 /**
207 * Queue a pixel URL for batch sending.
208 *
209 * @param string $pixel The pixel URL to queue.
210 */
211 private static function queue_pixel_for_batch( $pixel ) {
212 self::$pixel_batch_queue[] = $pixel;
213
214 // Register shutdown hook once.
215 if ( ! self::$shutdown_hook_registered ) {
216 add_action( 'shutdown', array( __CLASS__, 'send_batched_pixels' ), 20 );
217 self::$shutdown_hook_registered = true;
218 }
219 }
220
221 /**
222 * Send all queued pixels using batched non-blocking requests.
223 * This runs on the shutdown hook to batch all requests together.
224 *
225 * Uses Pixel_Builder for the actual sending via Requests library.
226 */
227 public static function send_batched_pixels() {
228 if ( empty( self::$pixel_batch_queue ) ) {
229 return;
230 }
231
232 // Delegate to Pixel_Builder for batched sending.
233 Pixel_Builder::send_pixels_batched( self::$pixel_batch_queue );
234
235 // Clear the queue.
236 self::$pixel_batch_queue = array();
237 }
238
239 /**
240 * Request-scoped — not for page output, see `get_page_common_properties()`.
241 *
242 * Includes the session cookie and `get_server_details()`, so this is only safe
243 * for events the server fires itself on an uncached request, which includes the
244 * proxy tracking endpoint.
245 *
246 * @return array The common properties.
247 */
248 public static function get_common_properties() {
249 return array_merge(
250 self::get_session_properties(),
251 self::get_page_common_properties(),
252 self::get_server_details()
253 );
254 }
255
256 /**
257 * Get the visitor's session properties from the session cookie.
258 *
259 * Request-derived, so these are for the server-fired path only. The cookie is
260 * written and read by the client's own SessionManager, which supplies these
261 * properties directly on events it sends.
262 *
263 * @since 0.16.7
264 *
265 * @return array The session properties.
266 */
267 private static function get_session_properties() {
268 $session_details = self::get_session_details();
269
270 return array(
271 'session_id' => $session_details['session_id'] ?? null,
272 'landing_page' => $session_details['landing_page'] ?? null,
273 'is_engaged' => $session_details['is_engaged'] ?? null,
274 );
275 }
276
277 /**
278 * Get the common properties that are safe to embed in cacheable page HTML.
279 *
280 * Request headers and cookies are not part of the CDN cache key, so a property
281 * derived from one is attributed to every later visitor of the cached page.
282 * Anything request-derived belongs in `get_session_properties()` or
283 * `get_server_details()`, which only reach the server-fired path.
284 *
285 * Two exceptions, neither of them licence to add a third: `device` is
286 * User-Agent derived and a known gap, tracked for a client-side follow-up;
287 * `ui`, `is_guest` and `store_admin` are safe only because caches bypass
288 * logged-in requests.
289 *
290 * @since 0.16.7
291 *
292 * @return array The common properties.
293 */
294 public static function get_page_common_properties() {
295 $blog_user_id = self::get_blog_user_id();
296 $blog_details = self::get_blog_details();
297
298 return array(
299 'ui' => $blog_user_id,
300 'blog_id' => $blog_details['blog_id'] ?? null,
301 'store_id' => $blog_details['store_id'] ?? null,
302 'url' => $blog_details['url'] ?? null,
303 'woo_version' => $blog_details['wc_version'] ?? null,
304 'wp_version' => get_bloginfo( 'version' ),
305 'store_admin' => count( array_intersect( array( 'administrator', 'shop_manager' ), wp_get_current_user()->roles ) ) > 0 ? 1 : 0,
306 'device' => self::get_device_type(),
307 'store_currency' => $blog_details['store_currency'] ?? null,
308 'timezone' => wp_timezone_string(),
309 'is_guest' => ( $blog_user_id === null || $blog_user_id === 0 ) ? 1 : 0,
310 );
311 }
312
313 /**
314 * Get all properties for the event including filtered and identity properties.
315 *
316 * @param string $event_name Event name.
317 * @param array $event_properties Event specific properties.
318 * @return array
319 */
320 public static function get_properties( $event_name, $event_properties ) {
321 $common_properties = self::get_common_properties();
322
323 /**
324 * Allow defining custom event properties in WooCommerce Analytics.
325 *
326 * @module woocommerce-analytics
327 *
328 * @since 12.5
329 *
330 * @param array $all_props Array of event props to be filtered.
331 */
332 $properties = apply_filters( 'jetpack_woocommerce_analytics_event_props', array_merge( $common_properties, $event_properties ), $event_name );
333
334 $required_properties = $event_name
335 ? array(
336 '_en' => $event_name,
337 '_ts' => Pixel_Builder::build_timestamp(),
338 '_ut' => 'anon',
339 '_ui' => self::get_visitor_id(),
340 )
341 : array();
342
343 $all_properties = array_merge( $properties, $required_properties );
344
345 // Convert array values to a comma-separated string and URL-encode them to ensure compatibility with JavaScript's encodeURIComponent() for pixel URL transmission.
346 foreach ( $all_properties as $key => $value ) {
347 if ( ! is_array( $value ) ) {
348 continue;
349 }
350
351 if ( empty( $value ) ) {
352 $all_properties[ $key ] = '';
353 continue;
354 }
355
356 $is_indexed_array = array_keys( $value ) === range( 0, count( $value ) - 1 );
357 if ( $is_indexed_array ) {
358 $value_string = implode( ',', $value );
359 $all_properties[ $key ] = rawurlencode( $value_string );
360 continue;
361 }
362
363 // Serialize non-indexed arrays to JSON strings.
364 $all_properties[ $key ] = wp_json_encode( $value, JSON_UNESCAPED_SLASHES );
365 }
366
367 return $all_properties;
368 }
369
370 /**
371 * Get the current user id.
372 *
373 * @return int The user ID, or 0 if not logged in.
374 */
375 private static function get_blog_user_id() {
376 // Ensure cookie constants are defined.
377 if ( ! defined( 'LOGGED_IN_COOKIE' ) ) {
378 if ( function_exists( 'wp_cookie_constants' ) ) {
379 wp_cookie_constants();
380 } else {
381 require_once ABSPATH . WPINC . '/default-constants.php';
382 wp_cookie_constants();
383 }
384 }
385
386 if ( function_exists( 'get_current_user_id' ) && get_current_user_id() ) {
387 return get_current_user_id();
388 }
389
390 // Manually validate the logged_in cookie
391 if ( ! function_exists( 'wp_validate_auth_cookie' ) ) {
392 require_once ABSPATH . WPINC . '/pluggable.php';
393 }
394
395 $user_id = wp_validate_auth_cookie( '', 'logged_in' );
396
397 return $user_id ? (int) $user_id : 0;
398 }
399
400 /**
401 * Gather details from the request to the server.
402 *
403 * This method is now standalone and doesn't rely on WC_Tracks parent class.
404 *
405 * @return array Server details.
406 */
407 public static function get_server_details() {
408 // Sanitization helper - use wc_clean if available, otherwise sanitize_text_field.
409 $clean = function_exists( 'wc_clean' ) ? 'wc_clean' : 'sanitize_text_field';
410
411 $data = array(
412 '_via_ua' => isset( $_SERVER['HTTP_USER_AGENT'] ) ? $clean( wp_unslash( $_SERVER['HTTP_USER_AGENT'] ) ) : '', // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
413 '_via_ip' => self::get_user_ip_address(),
414 '_lg' => isset( $_SERVER['HTTP_ACCEPT_LANGUAGE'] ) ? substr( sanitize_text_field( wp_unslash( $_SERVER['HTTP_ACCEPT_LANGUAGE'] ) ), 0, 5 ) : '',
415 '_dr' => isset( $_SERVER['HTTP_REFERER'] ) ? $clean( wp_unslash( $_SERVER['HTTP_REFERER'] ) ) : '', // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
416 );
417
418 // Build the document location URL.
419 $uri = isset( $_SERVER['REQUEST_URI'] ) ? $clean( wp_unslash( $_SERVER['REQUEST_URI'] ) ) : ''; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
420 $host = isset( $_SERVER['HTTP_HOST'] ) ? $clean( wp_unslash( $_SERVER['HTTP_HOST'] ) ) : ''; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
421 $data['_dl'] = isset( $_SERVER['REQUEST_SCHEME'] ) ? $clean( wp_unslash( $_SERVER['REQUEST_SCHEME'] ) ) . '://' . $host . $uri : ''; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
422
423 // Add _via_ref (referrer) for backward compatibility.
424 // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
425 $data['_via_ref'] = isset( $_SERVER['HTTP_REFERER'] ) ? $clean( wp_unslash( $_SERVER['HTTP_REFERER'] ) ) : '';
426
427 return $data;
428 }
429
430 /**
431 * Get the blog details.
432 *
433 * This method is now standalone and doesn't rely on WC_Tracks parent class.
434 * It still works with WooCommerce when available for additional details.
435 *
436 * @return array The blog details.
437 */
438 public static function get_blog_details() {
439 // Try to get cached blog details.
440 $blog_details = get_transient( 'wc_analytics_blog_details' );
441
442 if ( false !== $blog_details ) {
443 return $blog_details;
444 }
445
446 // Get Jetpack blog ID if available.
447 $jetpack_blog_id = null;
448 if ( class_exists( 'Jetpack_Options' ) ) {
449 $jetpack_blog_id = \Jetpack_Options::get_option( 'id' );
450 }
451
452 // Get WooCommerce version if available.
453 // Check WC_VERSION constant first (most reliable), then fall back to option.
454 if ( defined( 'WC_VERSION' ) ) {
455 $wc_version = WC_VERSION;
456 } else {
457 $wc_version = get_option( 'woocommerce_version', '' );
458 }
459
460 // Get store ID from known option name.
461 $store_id = get_option( 'woocommerce_store_id', null );
462
463 // Get store currency - use WC function if available, otherwise fall back to option.
464 $store_currency = function_exists( 'get_woocommerce_currency' )
465 ? get_woocommerce_currency()
466 : get_option( 'woocommerce_currency', 'USD' );
467
468 $blog_details = array(
469 'url' => home_url(),
470 'blog_lang' => get_locale(),
471 'blog_id' => $jetpack_blog_id,
472 'store_id' => $store_id,
473 'wc_version' => $wc_version,
474 'store_currency' => $store_currency,
475 );
476
477 // Cache for 1 day.
478 set_transient( 'wc_analytics_blog_details', $blog_details, DAY_IN_SECONDS );
479
480 return $blog_details;
481 }
482
483 /**
484 * Get the session details as an array
485 *
486 * @return array
487 */
488 private static function get_session_details() {
489 // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- JSON is decoded and validated below. We don't need to sanitize the cookie value because we're not outputting it but decoding it as JSON. Sanitization might break the JSON.
490 $raw_cookie = isset( $_COOKIE['woocommerceanalytics_session'] ) ? wp_unslash( $_COOKIE['woocommerceanalytics_session'] ) : '';
491
492 if ( ! $raw_cookie ) {
493 return array();
494 }
495
496 $decoded = json_decode( rawurldecode( $raw_cookie ), true );
497 return is_array( $decoded ) ? $decoded : array();
498 }
499
500 /**
501 * Get the existing stable visitor id: the `tk_ai` cookie, or an IP-based hash when
502 * proxy tracking is enabled. Returns null otherwise so the caller skips the event.
503 *
504 * We never mint a new id here: attributing an event to a brand-new id creates a
505 * throwaway one-event "visitor" (mostly cookie-less crawlers) that inflates session
506 * counts. Real browsers already have a `tk_ai` cookie by the time an event fires.
507 *
508 * @return string|null Stable visitor id, or null when none is available.
509 */
510 private static function get_visitor_id() {
511 // Return cached result if available.
512 if ( null !== self::$cached_visitor_id ) {
513 return self::$cached_visitor_id;
514 }
515
516 // Prefer the tk_ai cookie if present.
517 if ( ! empty( $_COOKIE['tk_ai'] ) ) {
518 self::$cached_visitor_id = sanitize_text_field( wp_unslash( $_COOKIE['tk_ai'] ) );
519 return self::$cached_visitor_id;
520 }
521
522 // Cron and WP-CLI have no real visitor; never attribute background activity to one.
523 if ( ( defined( 'DOING_CRON' ) && DOING_CRON )
524 || ( defined( 'WP_CLI' ) && WP_CLI )
525 ) {
526 return null;
527 }
528
529 // Proxy tracking provides a stable id from daily_salt + domain + ip + user_agent.
530 if ( Features::is_proxy_tracking_enabled() ) {
531 self::$cached_visitor_id = self::get_ip_based_visitor_id();
532 return self::$cached_visitor_id;
533 }
534
535 // No stable id arrived with the request. Do not mint one (see method doc).
536 return null;
537 }
538
539 /**
540 * Get the user's IP address.
541 *
542 * @return string The user's IP address. An empty string if no valid IP address is found.
543 */
544 private static function get_user_ip_address() {
545 // Return cached IP if available
546 if ( null !== self::$cached_ip ) {
547 return self::$cached_ip;
548 }
549
550 $ip_headers = array(
551 'HTTP_CF_CONNECTING_IP', // Cloudflare specific header.
552 'HTTP_X_FORWARDED_FOR',
553 'REMOTE_ADDR',
554 'HTTP_CLIENT_IP',
555 );
556
557 foreach ( $ip_headers as $header ) {
558 if ( isset( $_SERVER[ $header ] ) ) {
559 $ip_list = explode( ',', wp_unslash( $_SERVER[ $header ] ) ); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
560 foreach ( $ip_list as $ip_candidate ) {
561 $ip_candidate = trim( $ip_candidate );
562 if ( filter_var(
563 $ip_candidate,
564 FILTER_VALIDATE_IP,
565 array( FILTER_FLAG_NO_RES_RANGE, FILTER_FLAG_IPV6 )
566 ) ) {
567 // Cache the resolved IP
568 self::$cached_ip = $ip_candidate;
569 return self::$cached_ip;
570 }
571 }
572 }
573 }
574
575 // Cache empty result
576 self::$cached_ip = '';
577 return self::$cached_ip;
578 }
579
580 /**
581 * Get IP-based visitor ID for proxy tracking mode.
582 *
583 * @return string|null
584 */
585 private static function get_ip_based_visitor_id() {
586 $ip = self::get_user_ip_address();
587 if ( empty( $ip ) ) {
588 return null;
589 }
590
591 $salt = self::get_daily_salt();
592 $url_parts = wp_parse_url( home_url() );
593 $domain = $url_parts['host'] ?? '';
594 $user_agent = sanitize_text_field( wp_unslash( $_SERVER['HTTP_USER_AGENT'] ?? '' ) );
595
596 // Create hash from: daily_salt + domain + ip + user_agent
597 $hash_input = $salt . $domain . $ip . $user_agent;
598
599 return substr( hash( 'sha256', $hash_input ), 0, 16 );
600 }
601
602 /**
603 * Get or generate daily salt for visitor ID hashing.
604 * Creates a new salt value each day (UTC) for privacy protection.
605 *
606 * @return string The daily salt.
607 */
608 private static function get_daily_salt() {
609 $today = gmdate( 'Y-m-d' ); // UTC date
610
611 $salt_data = get_option( self::DAILY_SALT_OPTION );
612
613 // Check if salt exists and is still valid for today
614 if (
615 is_array( $salt_data )
616 && isset( $salt_data['date'] )
617 && isset( $salt_data['salt'] )
618 && $salt_data['date'] === $today
619 ) {
620 return $salt_data['salt'];
621 }
622
623 // Generate new salt for today
624 $new_salt = wp_generate_password( 32, false );
625
626 // Store salt with date (no expiration time needed)
627 $salt_data = array(
628 'date' => $today,
629 'salt' => $new_salt,
630 );
631
632 update_option( self::DAILY_SALT_OPTION, $salt_data );
633 return $new_salt;
634 }
635
636 /**
637 * Get the device type for the current request.
638 *
639 * Uses Jetpack Device Detection to distinguish between mobile phones, tablets, and desktop devices.
640 *
641 * @return string 'mobile' for phones, 'tablet' for tablets, 'desktop' otherwise.
642 */
643 private static function get_device_type() {
644 if ( Device_Detection::is_phone() ) {
645 return 'mobile';
646 }
647
648 if ( Device_Detection::is_tablet() ) {
649 return 'tablet';
650 }
651
652 return 'desktop';
653 }
654 }
655