PluginProbe ʕ •ᴥ•ʔ
Jetpack – WP Security, Backup, Speed, & Growth / 16.2-a.1
Jetpack – WP Security, Backup, Speed, & Growth v16.2-a.1
16.1.2 16.2-a.1 16.1.1 16.1 16.1-beta 16.1-beta.2 16.1-beta.3 16.1-a.5 16.1-a.3 16.0.1 16.1-a.1 16.0 16.0-beta 16.0-a.7 16.0-a.5 15.9.1 16.0-a.3 16.0-a.1 15.9 15.9-beta 15.9-a.7 15.9-a.5 15.9-a.3 15.9-a.1 15.8 15.8-beta 15.8-a.7 15.8-a.5 5.2.5 5.3.4 5.4.4 5.5.5 5.6.5 5.7.5 5.8.4 5.9.4 6.0.4 6.1 6.1.1 6.1.2 6.1.3 6.1.4 6.1.5 6.2 6.2.1 6.2.2 6.2.3 6.2.4 6.2.5 6.3 6.3.1 6.3.2 6.3.3 6.3.4 6.3.5 6.3.6 6.3.7 6.4 6.4.1 6.4.2 6.4.3 6.4.4 6.4.5 6.4.6 6.5 6.5.1 6.5.2 6.5.3 6.5.4 6.6 6.6.1 6.6.2 6.6.3 6.6.4 6.6.5 6.7 6.7.1 6.7.2 6.7.3 6.7.4 6.8 6.8.1 6.8.2 6.8.3 6.8.4 6.8.5 6.9 6.9.1 6.9.2 6.9.3 6.9.4 7.0 7.0.1 7.0.2 7.0.3 7.0.4 7.0.5 7.1 7.1.1 7.1.2 7.1.3 7.1.4 7.1.5 7.2 7.2.1 7.2.1.1 7.2.2 7.2.3 7.2.4 7.2.5 7.3 7.3.0.1 7.3.1 7.3.1.1 7.3.2 7.3.3 7.3.4 7.3.5 7.4 7.4.1 7.4.2 7.4.3 7.4.4 7.4.5 7.5 7.5.0.1 7.5.1 7.5.2 7.5.3 7.5.4 7.5.5 7.5.6 7.5.7 7.6 7.6.1 7.6.2 7.6.3 7.6.4 7.7 7.7.1 7.7.2 7.7.3 7.7.4 7.7.5 7.7.6 7.8 7.8.1 7.8.2 7.8.3 7.8.4 7.9 7.9.1 7.9.2 7.9.3 7.9.4 8.0 8.0.1 8.0.2 8.0.3 8.1 8.1.1 8.1.2 8.1.3 8.1.4 8.2 8.2.0.1 8.2.1 8.2.2 8.2.3 8.2.4 8.2.5 8.2.6 8.3 8.3.1 8.3.2 8.3.3 8.4 8.4.1 8.4.2 8.4.3 8.4.4 8.4.5 8.5 8.5.1 8.5.2 8.5.3 8.6 8.6.1 8.6.2 8.6.3 8.6.4 8.7 8.7.0.1 8.7.1 8.7.2 8.7.3 8.7.4 8.8 8.8.1 8.8.2 8.8.3 8.8.4 8.8.5 8.9 8.9.1 8.9.2 8.9.3 8.9.4 9.0 9.0.1 9.0.2 9.0.3 9.0.4 9.0.5 9.1 9.1.1 9.1.2 9.1.3 9.2 9.2.1 9.2.2 9.2.3 9.2.4 9.3 9.3.1 9.3.2 9.3.3 9.3.4 9.3.5 9.4 9.4.1 9.4.2 9.4.3 9.4.4 9.5 9.5.1 9.5.2 9.5.3 9.5.4 9.5.5 9.6 9.6.1 9.6.2 9.6.3 9.6.4 9.7 9.7.1 9.7.2 15.7-beta.2 9.7.3 15.7.1 9.8 15.8-a.1 9.8.1 15.8-a.3 9.8.2 2.0.9 9.8.3 2.1.7 9.9 2.2.10 9.9.1 2.3.10 9.9.2 2.4.7 9.9.3 2.5.5 2.6.6 2.7.5 2.8.5 2.9.6 3.0.6 3.1.5 3.2.5 3.3.6 3.4.6 3.5.6 3.6.4 3.7.5 3.8.5 3.9.10 4.0.7 4.1.4 4.2.5 4.3.5 4.4.5 4.5.3 4.6.3 4.7.4 4.8.5 4.9.3 5.0.3 5.1.4 trunk 10.0 10.0.1 10.0.2 10.1 10.1.1 10.1.2 10.2 10.2.1 10.2.2 10.2.3 10.3 10.3.1 10.3.2 10.4 10.4.1 10.4.2 10.5 10.5.1 10.5.2 10.5.3 10.6 10.6.1 10.6.2 10.7 10.7.1 10.7.2 10.8 10.8.1 10.8.2 10.9 10.9.1 10.9.2 10.9.3 11.0 11.0.1 11.0.2 11.1 11.1.1 11.1.2 11.1.3 11.1.4 11.2 11.2.1 11.2.2 11.3 11.3.1 11.3.2 11.3.3 11.3.4 11.4 11.4.1 11.4.2 11.5 11.5.1 11.5.2 11.5.3 11.6 11.6.1 11.6.2 11.7 11.7.1 11.7.2 11.7.3 11.8 11.8.3 11.8.4 11.8.5 11.8.6 11.9 11.9.1 11.9.2 11.9.3 12.0 12.0.1 12.0.2 12.1 12.1.1 12.1.2 12.2 12.2.1 12.2.2 12.3 12.3.1 12.4 12.4.1 12.5 12.5.1 12.6 12.6.1 12.6.2 12.6.3 12.7 12.7.1 12.7.2 12.8 12.8.1 12.8.2 12.9 12.9.1 12.9.2 12.9.3 12.9.4 13.0 13.0.1 13.1 13.1.1 13.1.2 13.1.3 13.1.4 13.2 13.2.1 13.2.2 13.2.3 13.3 13.3.1 13.3.2 13.4 13.4.1 13.4.2 13.4.3 13.4.4 13.5 13.5.1 13.6 13.6.1 13.7 13.7.1 13.8 13.8.1 13.8.2 13.9 13.9.1 14.0 14.1 14.2 14.2.1 14.3 14.4 14.4.1 14.5 14.6 14.7 14.8 14.9 14.9.1 15.0 15.0.1 15.0.2 15.1 15.1.1 15.2 15.3 15.3.1 15.4 15.5 15.6 15.7 15.7-a.1 15.7-a.3 15.7-a.5 15.7-a.7 15.7-beta
jetpack / jetpack_vendor / automattic / woocommerce-analytics / src / class-wc-analytics-tracking.php
jetpack / jetpack_vendor / automattic / woocommerce-analytics / src Last commit date
API 8 months ago mu-plugin 5 months ago class-consent-manager.php 8 months ago class-features.php 5 months ago class-my-account.php 8 months ago class-pixel-builder.php 5 months ago class-universal.php 3 weeks ago class-wc-analytics-tracking.php 3 weeks ago class-woo-analytics-trait.php 3 weeks ago class-woocommerce-analytics.php 3 weeks ago
class-wc-analytics-tracking.php
655 lines
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