PluginProbe
Jetpack – WP Security, Backup, Speed, & Growth / 16.3-a.7
Jetpack – WP Security, Backup, Speed, & Growth v16.3-a.7
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.7, at jetpack_vendor/automattic/woocommerce-analytics/src/class-wc-analytics-tracking.php

1,145 lines 34.8 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 Automattic\Woocommerce_Analytics;
16 use WP_Error;
17
18 /**
19 * WooCommerce Analytics Tracking class
20 */
21 class WC_Analytics_Tracking {
22 /**
23 * Event prefix.
24 *
25 * @var string
26 */
27 const PREFIX = 'woocommerceanalytics_';
28
29 /**
30 * Option name for storing daily salt data.
31 *
32 * @var string
33 */
34 const DAILY_SALT_OPTION = 'woocommerce_analytics_daily_salt';
35
36 /**
37 * Property names a client is authoritative for on the proxy path.
38 *
39 * The server's own values for these describe the /track request, not the page
40 * the event happened on. `_via_ref` is excluded despite sharing a header with
41 * `_dr`: it records what fired the pixel, and is not used for page attribution.
42 *
43 * @since 0.18.0
44 *
45 * @var string[]
46 */
47 const CLIENT_OVERRIDABLE_PROPERTIES = array( '_lg', '_dl', '_dr' );
48
49 /**
50 * Identity and envelope property names a client may never set.
51 *
52 * Each is already protected by merge ordering in `get_properties()` or by
53 * `Pixel_Builder::validate_and_sanitize()`. Listed anyway so that neither is
54 * the only thing standing between a client and the visitor id.
55 *
56 * @since 0.18.0
57 *
58 * @var string[]
59 */
60 const RESERVED_IDENTITY_PROPERTIES = array( '_ui', '_ut', '_en', '_ts', 'browser_type' );
61
62 /**
63 * Maximum number of events a single client request may record.
64 *
65 * Prevents the unauthenticated endpoint from creating unbounded pixel requests.
66 *
67 * @since 0.18.0
68 *
69 * @var int
70 */
71 const MAX_CLIENT_EVENTS_PER_REQUEST = 50;
72
73 /**
74 * Maximum number of properties a client may set on one event.
75 *
76 * @since 0.18.0
77 *
78 * @var int
79 */
80 const MAX_CLIENT_PROPERTIES_PER_EVENT = 50;
81
82 /**
83 * Maximum length of a single property value bound for the pixel URL.
84 *
85 * The payload limit still caps the full event, while this preserves attribution URLs.
86 *
87 * @since 0.18.0
88 *
89 * @var int
90 */
91 const MAX_CLIENT_PROPERTY_LENGTH = 1000;
92
93 /**
94 * Maximum length of a client-supplied event or property name.
95 *
96 * `Pixel_Builder` validates characters but not length.
97 *
98 * @since 0.18.0
99 *
100 * @var int
101 */
102 const MAX_CLIENT_NAME_LENGTH = 100;
103
104 /**
105 * Maximum number of members in a client-supplied array value.
106 *
107 * Avoids excessive work while fitting an array into the payload budget.
108 *
109 * @since 0.18.0
110 *
111 * @var int
112 */
113 const MAX_CLIENT_ARRAY_MEMBERS = 50;
114
115 /**
116 * Maximum total length of one event's client-supplied properties.
117 *
118 * Counts percent-encoded URL bytes, which can exceed a value's character count.
119 *
120 * @since 0.18.0
121 *
122 * @var int
123 */
124 const MAX_CLIENT_PAYLOAD_LENGTH = 4096;
125
126 /**
127 * Maximum length of a pixel URL this package will fire.
128 *
129 * This also bounds properties added after client properties are sanitized.
130 *
131 * @since 0.18.0
132 *
133 * @var int
134 */
135 const MAX_PIXEL_URL_LENGTH = 8192;
136
137 /**
138 * Event queue.
139 *
140 * @var array
141 */
142 protected static $event_queue = array();
143
144 /**
145 * Batch pixel queue for batched requests.
146 *
147 * @var array
148 */
149 private static $pixel_batch_queue = array();
150
151 /**
152 * Whether the shutdown hook has been registered.
153 *
154 * @var bool
155 */
156 private static $shutdown_hook_registered = false;
157
158 /**
159 * Cached user IP address for the current request.
160 *
161 * @var string|null
162 */
163 private static $cached_ip = null;
164
165 /**
166 * Cached visitor ID for the current request.
167 *
168 * @var string|null
169 */
170 private static $cached_visitor_id = null;
171
172 /**
173 * Memoized reserved property names for the current request.
174 *
175 * @var string[]|null
176 */
177 private static $reserved_property_names = null;
178
179 /**
180 * Record an event in Tracks and ClickHouse (If enabled).
181 *
182 * @since 0.18.0 Added the `$is_client_supplied` parameter.
183 *
184 * @param string $event_name The name of the event.
185 * @param array $event_properties Custom properties to send with the event.
186 * @param bool $is_client_supplied Whether $event_properties came from an untrusted
187 * client. Reserved property names are stripped and the
188 * rest are bounded when true. Defaults to false for
189 * server-side callers.
190 *
191 * @return bool|WP_Error True on emit or deliberate skip (no consent, bot UA, or
192 * cookie-less context); WP_Error for an unusable client
193 * event name, or if the pixel could not be built or fired.
194 */
195 public static function record_event( $event_name, $event_properties = array(), $is_client_supplied = false ) {
196 // Check consent before recording any event.
197 if ( ! Consent_Manager::has_analytics_consent() ) {
198 return true; // Skip recording.
199 }
200
201 // Skip recording if the request is coming from a bot.
202 if ( User_Agent_Info::is_bot() ) {
203 return true;
204 }
205
206 // Skip events that arrive without a stable visitor id (e.g. no tk_ai cookie); see get_visitor_id().
207 if ( empty( self::get_visitor_id() ) ) {
208 return true;
209 }
210
211 if ( $is_client_supplied ) {
212 // Report invalid names because they cannot produce an event.
213 if ( ! self::is_valid_client_name( $event_name ) ) {
214 return new WP_Error( 'invalid_event_name', 'the event name is empty, too long, or not a string', 400 );
215 }
216
217 $event_properties = self::sanitize_client_properties( $event_properties );
218 }
219
220 $prefixed_event_name = self::PREFIX . $event_name;
221 $properties = self::get_properties( $prefixed_event_name, $event_properties, $is_client_supplied );
222
223 // Record Tracks event.
224 $tracks_error = null;
225 $tracks_result = self::record_tracks_event( $properties );
226 if ( is_wp_error( $tracks_result ) ) {
227 $tracks_error = $tracks_result;
228 }
229
230 // Record ClickHouse event, if applicable.
231 $ch_error = null;
232 if ( Features::is_clickhouse_enabled() || ( isset( $properties['ch'] ) && 1 === (int) $properties['ch'] ) ) {
233 $properties['ch'] = 1;
234 $ch_result = self::record_ch_event( $properties );
235 if ( is_wp_error( $ch_result ) ) {
236 $ch_error = $ch_result;
237 }
238 }
239
240 // If both failed, return the Tracks error (primary), else the CH error, else true.
241 if ( $tracks_error ) {
242 return $tracks_error;
243 }
244 if ( $ch_error ) {
245 return $ch_error;
246 }
247
248 return true;
249 }
250
251 /**
252 * Record an event whose properties came from an untrusted client.
253 *
254 * The entry point for the tracking proxy: the REST controller and the MU-plugin
255 * speed module both come through here. A distinct method rather than a sanitizer
256 * callers must remember to invoke, so a wrong choice is visible at the call site.
257 *
258 * @since 0.18.0
259 *
260 * @param string $event_name The name of the event.
261 * @param array $event_properties Client-supplied properties.
262 *
263 * @return bool|WP_Error True on emit or deliberate skip; WP_Error if pixel firing failed.
264 */
265 public static function record_client_event( $event_name, $event_properties = array() ) {
266 return self::record_event( $event_name, $event_properties, true );
267 }
268
269 /**
270 * Queue an event in the event queue which will be processed on the page load in client-side analytics.
271 *
272 * @param string $event_name The name of the event.
273 * @param array $properties The event properties.
274 */
275 public static function add_event_to_queue( $event_name, $properties = array() ) {
276 self::$event_queue[] = array(
277 'eventName' => $event_name,
278 'props' => $properties,
279 );
280 }
281
282 /**
283 * Get the event queue.
284 *
285 * @return array The event queue.
286 */
287 public static function get_event_queue() {
288 return self::$event_queue;
289 }
290
291 /**
292 * Record an event in Tracks.
293 *
294 * @param array $properties Properties to send with the event.
295 * @return bool|WP_Error True for success or WP_Error if the event pixel could not be fired.
296 */
297 private static function record_tracks_event( $properties = array() ) {
298 $pixel_url = Pixel_Builder::build_tracks_url( $properties );
299
300 if ( is_wp_error( $pixel_url ) ) {
301 return $pixel_url;
302 }
303
304 return self::record_pixel_url( $pixel_url );
305 }
306
307 /**
308 * Record a ClickHouse event.
309 *
310 * @param array $properties The event properties.
311 * @return bool|WP_Error True for success or WP_Error if the event pixel could not be fired.
312 */
313 private static function record_ch_event( $properties ) {
314 $pixel_url = Pixel_Builder::build_ch_url( $properties );
315
316 if ( is_wp_error( $pixel_url ) ) {
317 return $pixel_url;
318 }
319
320 return self::record_pixel_url( $pixel_url );
321 }
322
323 /**
324 * Record a pixel URL using batching.
325 *
326 * @param string $pixel_url The pixel URL to record.
327 * @return bool|WP_Error True for success or WP_Error if the event pixel could not be fired.
328 */
329 private static function record_pixel_url( $pixel_url ) {
330 if ( empty( $pixel_url ) ) {
331 return new WP_Error( 'invalid_pixel', 'cannot generate tracks pixel for given input', 400 );
332 }
333
334 if ( strlen( $pixel_url ) > self::MAX_PIXEL_URL_LENGTH ) {
335 // The proxy endpoint reports this error back to its caller, but no
336 // first-party call site checks the return value, so for those events the
337 // log line is the only signal that one was dropped.
338 $error_message = sprintf(
339 'WooCommerce Analytics: dropped a %d byte pixel, over the %d byte limit.',
340 strlen( $pixel_url ),
341 self::MAX_PIXEL_URL_LENGTH
342 );
343 if ( function_exists( 'wc_get_logger' ) ) {
344 wc_get_logger()->warning( $error_message, array( 'source' => 'woocommerce-analytics' ) );
345 } else {
346 // Fallback for MU-plugin stage when WooCommerce logger is not available.
347 // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
348 error_log( $error_message );
349 }
350
351 return new WP_Error( 'pixel_too_long', 'tracks pixel URL exceeds the maximum length', 400 );
352 }
353
354 // Check if batching is supported.
355 $can_batch = ( class_exists( 'WpOrg\Requests\Requests' ) && method_exists( 'WpOrg\Requests\Requests', 'request_multiple' ) )
356 || ( class_exists( 'Requests' ) && method_exists( 'Requests', 'request_multiple' ) );
357
358 if ( $can_batch ) {
359 // Queue the pixel and send on shutdown.
360 self::queue_pixel_for_batch( $pixel_url );
361 } else {
362 // Send immediately as batching is not supported.
363 Pixel_Builder::send_pixel( $pixel_url );
364 }
365
366 return true;
367 }
368
369 /**
370 * Queue a pixel URL for batch sending.
371 *
372 * @param string $pixel The pixel URL to queue.
373 */
374 private static function queue_pixel_for_batch( $pixel ) {
375 self::$pixel_batch_queue[] = $pixel;
376
377 // Register shutdown hook once.
378 if ( ! self::$shutdown_hook_registered ) {
379 add_action( 'shutdown', array( __CLASS__, 'send_batched_pixels' ), 20 );
380 self::$shutdown_hook_registered = true;
381 }
382 }
383
384 /**
385 * Send all queued pixels using batched non-blocking requests.
386 * This runs on the shutdown hook to batch all requests together.
387 *
388 * Uses Pixel_Builder for the actual sending via Requests library.
389 */
390 public static function send_batched_pixels() {
391 if ( empty( self::$pixel_batch_queue ) ) {
392 return;
393 }
394
395 // Delegate to Pixel_Builder for batched sending.
396 Pixel_Builder::send_pixels_batched( self::$pixel_batch_queue );
397
398 // Clear the queue.
399 self::$pixel_batch_queue = array();
400 }
401
402 /**
403 * Request-scoped — not for page output, see `get_page_common_properties()`.
404 *
405 * Includes the session cookie and `get_server_details()`, so this is only safe
406 * for events the server fires itself on an uncached request, which includes the
407 * proxy tracking endpoint.
408 *
409 * @return array The common properties.
410 */
411 public static function get_common_properties() {
412 return array_merge(
413 self::get_session_properties(),
414 self::get_page_common_properties(),
415 self::get_server_details()
416 );
417 }
418
419 /**
420 * Get the visitor's session properties from the session cookie.
421 *
422 * Request-derived, so these are for the server-fired path only. The cookie is
423 * written and read by the client's own SessionManager, which supplies these
424 * properties directly on events it sends.
425 *
426 * @since 0.16.7
427 *
428 * @return array The session properties.
429 */
430 private static function get_session_properties() {
431 $session_details = self::get_session_details();
432
433 // The client-writable cookie also affects first-party events.
434 return array(
435 'session_id' => self::cap_property_value( $session_details['session_id'] ?? null ),
436 'landing_page' => self::cap_json_list_value( $session_details['landing_page'] ?? null ),
437 'is_engaged' => self::cap_property_value( $session_details['is_engaged'] ?? null ),
438 );
439 }
440
441 /**
442 * Get the common properties that are safe to embed in cacheable page HTML.
443 *
444 * Request headers and cookies are not part of the CDN cache key, so a property
445 * derived from one is attributed to every later visitor of the cached page.
446 * Anything request-derived belongs in `get_session_properties()` or
447 * `get_server_details()`, which only reach the server-fired path.
448 *
449 * Two exceptions, neither of them licence to add a third: `device` is
450 * User-Agent derived and a known gap, tracked for a client-side follow-up;
451 * `ui`, `is_guest` and `store_admin` are safe only because caches bypass
452 * logged-in requests.
453 *
454 * @since 0.16.7
455 *
456 * @return array The common properties.
457 */
458 public static function get_page_common_properties() {
459 $blog_user_id = self::get_blog_user_id();
460 $blog_details = self::get_blog_details();
461
462 return array(
463 'ui' => $blog_user_id,
464 'blog_id' => $blog_details['blog_id'] ?? null,
465 'store_id' => $blog_details['store_id'] ?? null,
466 'url' => $blog_details['url'] ?? null,
467 'woo_version' => $blog_details['wc_version'] ?? null,
468 'wp_version' => get_bloginfo( 'version' ),
469 'store_admin' => count( array_intersect( array( 'administrator', 'shop_manager' ), wp_get_current_user()->roles ) ) > 0 ? 1 : 0,
470 'device' => self::get_device_type(),
471 'store_currency' => $blog_details['store_currency'] ?? null,
472 'timezone' => wp_timezone_string(),
473 'is_guest' => ( $blog_user_id === null || $blog_user_id === 0 ) ? 1 : 0,
474 'package_version' => Woocommerce_Analytics::PACKAGE_VERSION,
475 );
476 }
477
478 /**
479 * Get all properties for the event including filtered and identity properties.
480 *
481 * @since 0.18.0 Added the `$is_client_supplied` parameter.
482 *
483 * @param string $event_name Event name.
484 * @param array $event_properties Event specific properties.
485 * @param bool $is_client_supplied Whether $event_properties came from an untrusted client.
486 * @return array
487 */
488 public static function get_properties( $event_name, $event_properties, $is_client_supplied = false ) {
489 $common_properties = self::get_common_properties();
490
491 /**
492 * Allow defining custom event properties in WooCommerce Analytics.
493 *
494 * On the proxy path (`$is_client_supplied`) a reserved name a callback returns
495 * is discarded, because the server re-asserts its own value below. Names a
496 * callback introduces are not reserved and are kept.
497 *
498 * @module woocommerce-analytics
499 *
500 * @since 12.5
501 * @since 0.18.0 Added the `$is_client_supplied` parameter.
502 *
503 * @param array $all_props Array of event props to be filtered.
504 * @param string $event_name Event name.
505 * @param bool $is_client_supplied Whether the props came from an untrusted client.
506 */
507 $properties = apply_filters(
508 'jetpack_woocommerce_analytics_event_props',
509 array_merge( $common_properties, $event_properties ),
510 $event_name,
511 $is_client_supplied
512 );
513
514 if ( $is_client_supplied ) {
515 // A callback that defers to an existing value hands a reserved property
516 // back to the client, which supplied it. Re-assert the server's own.
517 $properties = array_merge(
518 $properties,
519 array_intersect_key( $common_properties, array_flip( self::get_reserved_property_names() ) )
520 );
521 }
522
523 $required_properties = $event_name
524 ? array(
525 '_en' => $event_name,
526 '_ts' => Pixel_Builder::build_timestamp(),
527 '_ut' => 'anon',
528 '_ui' => self::get_visitor_id(),
529 )
530 : array();
531
532 $all_properties = array_merge( $properties, $required_properties );
533
534 foreach ( $all_properties as $key => $value ) {
535 $all_properties[ $key ] = self::flatten_property_value( $value );
536 }
537
538 return $all_properties;
539 }
540
541 /**
542 * Get the property names a client may not set.
543 *
544 * Derived from `get_common_properties()` rather than restated as a literal, so a
545 * newly added common property is protected with no edit here. The pinned list in
546 * `WC_Analytics_Tracking_Reserved_Props_Test` still fails on the addition, on
547 * purpose: protection is automatic, granting an exemption is not. Memoized because
548 * a batch would otherwise recompute the common properties once per event.
549 *
550 * @since 0.18.0
551 *
552 * @return string[] Reserved property names.
553 */
554 public static function get_reserved_property_names() {
555 if ( null !== self::$reserved_property_names ) {
556 return self::$reserved_property_names;
557 }
558
559 $server_owned = array_diff(
560 array_keys( self::get_common_properties() ),
561 self::CLIENT_OVERRIDABLE_PROPERTIES
562 );
563
564 self::$reserved_property_names = array_values(
565 array_unique( array_merge( $server_owned, self::RESERVED_IDENTITY_PROPERTIES ) )
566 );
567
568 return self::$reserved_property_names;
569 }
570
571 /**
572 * Remove server-owned properties from a client-supplied property array.
573 *
574 * Stripping is silent and the event still records: rejecting it would turn the
575 * endpoint into an oracle for probing the reserved list.
576 *
577 * @since 0.18.0
578 *
579 * @param array $event_properties Client-supplied properties. A non-array is
580 * tolerated, since the REST body is attacker-shaped.
581 * @return array Properties with reserved names removed; empty array for empty or
582 * non-array input.
583 */
584 public static function strip_reserved_properties( $event_properties ) {
585 if ( ! is_array( $event_properties ) || empty( $event_properties ) ) {
586 return array();
587 }
588
589 return array_diff_key(
590 $event_properties,
591 array_flip( self::get_reserved_property_names() )
592 );
593 }
594
595 /**
596 * Strip and bound a client-supplied property array.
597 *
598 * Keep rejected properties silent so the unauthenticated endpoint cannot expose its limits.
599 *
600 * @since 0.18.0
601 *
602 * @param array $event_properties Client-supplied properties.
603 * @return array Sanitized properties.
604 */
605 public static function sanitize_client_properties( $event_properties ) {
606 $event_properties = self::strip_reserved_properties( $event_properties );
607
608 if ( count( $event_properties ) > self::MAX_CLIENT_PROPERTIES_PER_EVENT ) {
609 $event_properties = array_slice( $event_properties, 0, self::MAX_CLIENT_PROPERTIES_PER_EVENT, true );
610 }
611
612 $values = array();
613 $costs = array();
614
615 foreach ( $event_properties as $key => $value ) {
616 // Dropped, not truncated: two long names could truncate to the same key.
617 if ( ! self::is_valid_client_name( $key ) || ! Pixel_Builder::prop_name_is_valid( $key ) ) {
618 continue;
619 }
620
621 // Arrays are flattened later by get_properties(); bound their members too.
622 if ( is_array( $value ) ) {
623 $value = array_map(
624 array( __CLASS__, 'cap_property_value' ),
625 array_slice( $value, 0, self::MAX_CLIENT_ARRAY_MEMBERS, true )
626 );
627 } else {
628 $value = self::cap_property_value( $value );
629 }
630
631 $values[ $key ] = $value;
632 $costs[ $key ] = strlen( $key ) + self::measure_client_value( $value );
633 }
634
635 // Preserve more properties by fitting the cheapest values first.
636 asort( $costs );
637
638 $budget = self::MAX_CLIENT_PAYLOAD_LENGTH;
639 $kept = array();
640
641 foreach ( $costs as $key => $cost ) {
642 $value = $values[ $key ];
643
644 // Trim values to keep them when their encoded form exceeds the remaining budget.
645 if ( $cost > $budget ) {
646 $room = $budget - strlen( $key );
647
648 $value = is_array( $value )
649 ? self::fit_client_array( $value, $room )
650 : self::fit_client_string( (string) $value, $room );
651
652 if ( array() === $value || '' === $value ) {
653 continue;
654 }
655
656 $cost = strlen( $key ) + self::measure_client_value( $value );
657 }
658
659 $budget -= $cost;
660 $kept[ $key ] = $value;
661 }
662
663 // Back into the order the caller sent, so the pixel is not reordered by cost.
664 return array_replace( array_intersect_key( $values, $kept ), $kept );
665 }
666
667 /**
668 * Whether a client-supplied event or property name is usable.
669 *
670 * Without the type check an array name reaches `PREFIX . $event_name` and writes
671 * a PHP warning to the log, unauthenticated.
672 *
673 * @since 0.18.0
674 *
675 * @param mixed $name Client-supplied name.
676 * @return bool True when the name is a non-empty string within the length bound.
677 */
678 private static function is_valid_client_name( $name ) {
679 return is_string( $name )
680 && '' !== $name
681 && mb_strlen( $name ) <= self::MAX_CLIENT_NAME_LENGTH;
682 }
683
684 /**
685 * Reduce one property value to the string that goes into the pixel URL.
686 *
687 * The payload budget uses this same conversion to measure array values accurately.
688 *
689 * @since 0.18.0
690 *
691 * @param mixed $value Property value.
692 * @return mixed The scalar it serializes to; non-array values are returned as-is.
693 */
694 private static function flatten_property_value( $value ) {
695 if ( ! is_array( $value ) ) {
696 return $value;
697 }
698
699 if ( empty( $value ) ) {
700 return '';
701 }
702
703 // Not URL-encoded here: http_build_query() in Pixel_Builder encodes the whole URL, so encoding twice stores `%2F` in Tracks.
704 if ( array_keys( $value ) === range( 0, count( $value ) - 1 ) ) {
705 return implode( ',', $value );
706 }
707
708 return wp_json_encode( $value, JSON_UNESCAPED_SLASHES );
709 }
710
711 /**
712 * Bytes one value contributes to the pixel URL.
713 *
714 * @since 0.18.0
715 *
716 * @param mixed $value Already-capped client value.
717 * @return int Byte count after `flatten_property_value()` and the encoding
718 * `http_build_query()` applies on top of it.
719 */
720 private static function measure_client_value( $value ) {
721 // Match http_build_query()'s RFC1738 encoding.
722 // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.urlencode_urlencode -- Deliberate: mirrors http_build_query()'s RFC1738 encoding so the budget measures the bytes the finished URL carries.
723 return strlen( urlencode( (string) self::flatten_property_value( $value ) ) );
724 }
725
726 /**
727 * Trim a string value until it fits the remaining budget.
728 *
729 * Uses binary search because each candidate must be encoded again.
730 *
731 * @since 0.18.0
732 *
733 * @param string $value Already-capped value.
734 * @param int $budget Bytes still available for this value.
735 * @return string The longest prefix that fits, with an ellipsis; empty when
736 * even one character does not.
737 */
738 private static function fit_client_string( $value, $budget ) {
739 if ( $budget <= 0 ) {
740 return '';
741 }
742
743 $low = 0;
744 $high = mb_strlen( $value );
745
746 while ( $low < $high ) {
747 $mid = (int) ceil( ( $low + $high ) / 2 );
748
749 if ( self::measure_client_value( self::truncate_value( $value, $mid ) ) <= $budget ) {
750 $low = $mid;
751 } else {
752 $high = $mid - 1;
753 }
754 }
755
756 return self::truncate_value( $value, $low );
757 }
758
759 /**
760 * Drop trailing members until an array value fits the remaining budget.
761 *
762 * @since 0.18.0
763 *
764 * @param array $members Already-capped members.
765 * @param int $budget Bytes still available for this value.
766 * @return array Members that fit; empty when even one does not.
767 */
768 private static function fit_client_array( $members, $budget ) {
769 while ( ! empty( $members ) && self::measure_client_value( $members ) > $budget ) {
770 array_pop( $members );
771 }
772
773 return $members;
774 }
775
776 /**
777 * Bound a value that carries a JSON list, without invalidating the JSON.
778 *
779 * Preserve valid JSON by removing trailing list entries instead of cutting text.
780 *
781 * @since 0.18.0
782 *
783 * @param mixed $value Caller-influenced value.
784 * @return mixed Bounded value, still valid JSON when it arrived as JSON.
785 */
786 private static function cap_json_list_value( $value ) {
787 if ( ! is_string( $value ) || mb_strlen( $value ) <= self::MAX_CLIENT_PROPERTY_LENGTH ) {
788 return self::cap_property_value( $value );
789 }
790
791 $decoded = json_decode( $value, true );
792 if ( ! is_array( $decoded ) ) {
793 return self::cap_property_value( $value );
794 }
795
796 while ( ! empty( $decoded ) ) {
797 $encoded = wp_json_encode( $decoded );
798
799 if ( is_string( $encoded ) && mb_strlen( $encoded ) <= self::MAX_CLIENT_PROPERTY_LENGTH ) {
800 return $encoded;
801 }
802
803 array_pop( $decoded );
804 }
805
806 return '[]';
807 }
808
809 /**
810 * Bound one value on its way to the pixel URL.
811 *
812 * Arrays and objects become empty strings to avoid warnings during flattening.
813 *
814 * @since 0.18.0
815 *
816 * @param mixed $value Caller-influenced value.
817 * @return mixed Bounded value.
818 */
819 private static function cap_property_value( $value ) {
820 if ( is_array( $value ) || is_object( $value ) ) {
821 return '';
822 }
823
824 if ( ! is_string( $value ) ) {
825 return $value;
826 }
827
828 if ( mb_strlen( $value ) <= self::MAX_CLIENT_PROPERTY_LENGTH ) {
829 return $value;
830 }
831
832 return self::truncate_value( $value, self::MAX_CLIENT_PROPERTY_LENGTH );
833 }
834
835 /**
836 * Cut a value to a character count, marking that it was cut.
837 *
838 * @since 0.18.0
839 *
840 * @param string $value Value to cut.
841 * @param int $length Characters the result may occupy, ellipsis included.
842 * @return string The cut value, or an empty string when nothing fits.
843 */
844 private static function truncate_value( $value, $length ) {
845 if ( $length <= 0 ) {
846 return '';
847 }
848
849 return mb_substr( $value, 0, $length - 1 ) . '…';
850 }
851
852 /**
853 * Get the current user id.
854 *
855 * @return int The user ID, or 0 if not logged in.
856 */
857 private static function get_blog_user_id() {
858 // Ensure cookie constants are defined.
859 if ( ! defined( 'LOGGED_IN_COOKIE' ) ) {
860 if ( function_exists( 'wp_cookie_constants' ) ) {
861 wp_cookie_constants();
862 } else {
863 require_once ABSPATH . WPINC . '/default-constants.php';
864 wp_cookie_constants();
865 }
866 }
867
868 if ( function_exists( 'get_current_user_id' ) && get_current_user_id() ) {
869 return get_current_user_id();
870 }
871
872 // Manually validate the logged_in cookie
873 if ( ! function_exists( 'wp_validate_auth_cookie' ) ) {
874 require_once ABSPATH . WPINC . '/pluggable.php';
875 }
876
877 $user_id = wp_validate_auth_cookie( '', 'logged_in' );
878
879 return $user_id ? (int) $user_id : 0;
880 }
881
882 /**
883 * Gather details from the request to the server.
884 *
885 * This method is now standalone and doesn't rely on WC_Tracks parent class.
886 *
887 * @return array Server details.
888 */
889 public static function get_server_details() {
890 // Sanitization helper - use wc_clean if available, otherwise sanitize_text_field.
891 $clean = function_exists( 'wc_clean' ) ? 'wc_clean' : 'sanitize_text_field';
892
893 $data = array(
894 '_via_ua' => isset( $_SERVER['HTTP_USER_AGENT'] ) ? $clean( wp_unslash( $_SERVER['HTTP_USER_AGENT'] ) ) : '', // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
895 '_via_ip' => self::get_user_ip_address(),
896 '_lg' => isset( $_SERVER['HTTP_ACCEPT_LANGUAGE'] ) ? substr( sanitize_text_field( wp_unslash( $_SERVER['HTTP_ACCEPT_LANGUAGE'] ) ), 0, 5 ) : '',
897 '_dr' => isset( $_SERVER['HTTP_REFERER'] ) ? $clean( wp_unslash( $_SERVER['HTTP_REFERER'] ) ) : '', // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
898 );
899
900 // Build the document location URL.
901 $uri = isset( $_SERVER['REQUEST_URI'] ) ? $clean( wp_unslash( $_SERVER['REQUEST_URI'] ) ) : ''; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
902 $host = isset( $_SERVER['HTTP_HOST'] ) ? $clean( wp_unslash( $_SERVER['HTTP_HOST'] ) ) : ''; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
903 $data['_dl'] = isset( $_SERVER['REQUEST_SCHEME'] ) ? $clean( wp_unslash( $_SERVER['REQUEST_SCHEME'] ) ) . '://' . $host . $uri : ''; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
904
905 // Add _via_ref (referrer) for backward compatibility.
906 // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
907 $data['_via_ref'] = isset( $_SERVER['HTTP_REFERER'] ) ? $clean( wp_unslash( $_SERVER['HTTP_REFERER'] ) ) : '';
908
909 // Headers are caller-supplied, and the referer lands here twice. Uncapped, one
910 // long Referer pushes the finished URL past MAX_PIXEL_URL_LENGTH and costs the
911 // whole event; capped, it costs the tail of one value. `_lg` is already bounded
912 // above and `_via_ip` is validated by get_user_ip_address().
913 foreach ( array( '_via_ua', '_dr', '_dl', '_via_ref' ) as $key ) {
914 $data[ $key ] = self::cap_property_value( $data[ $key ] );
915 }
916
917 return $data;
918 }
919
920 /**
921 * Get the blog details.
922 *
923 * This method is now standalone and doesn't rely on WC_Tracks parent class.
924 * It still works with WooCommerce when available for additional details.
925 *
926 * @return array The blog details.
927 */
928 public static function get_blog_details() {
929 // Try to get cached blog details.
930 $blog_details = get_transient( 'wc_analytics_blog_details' );
931
932 if ( false !== $blog_details ) {
933 return $blog_details;
934 }
935
936 // Get Jetpack blog ID if available.
937 $jetpack_blog_id = null;
938 if ( class_exists( 'Jetpack_Options' ) ) {
939 $jetpack_blog_id = \Jetpack_Options::get_option( 'id' );
940 }
941
942 // Get WooCommerce version if available.
943 // Check WC_VERSION constant first (most reliable), then fall back to option.
944 if ( defined( 'WC_VERSION' ) ) {
945 $wc_version = WC_VERSION;
946 } else {
947 $wc_version = get_option( 'woocommerce_version', '' );
948 }
949
950 // Get store ID from known option name.
951 $store_id = get_option( 'woocommerce_store_id', null );
952
953 // Get store currency - use WC function if available, otherwise fall back to option.
954 $store_currency = function_exists( 'get_woocommerce_currency' )
955 ? get_woocommerce_currency()
956 : get_option( 'woocommerce_currency', 'USD' );
957
958 $blog_details = array(
959 'url' => home_url(),
960 'blog_lang' => get_locale(),
961 'blog_id' => $jetpack_blog_id,
962 'store_id' => $store_id,
963 'wc_version' => $wc_version,
964 'store_currency' => $store_currency,
965 );
966
967 // Cache for 1 day.
968 set_transient( 'wc_analytics_blog_details', $blog_details, DAY_IN_SECONDS );
969
970 return $blog_details;
971 }
972
973 /**
974 * Get the session details as an array
975 *
976 * @return array
977 */
978 private static function get_session_details() {
979 // 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.
980 $raw_cookie = isset( $_COOKIE['woocommerceanalytics_session'] ) ? wp_unslash( $_COOKIE['woocommerceanalytics_session'] ) : '';
981
982 if ( ! $raw_cookie ) {
983 return array();
984 }
985
986 $decoded = json_decode( rawurldecode( $raw_cookie ), true );
987 return is_array( $decoded ) ? $decoded : array();
988 }
989
990 /**
991 * Get the existing stable visitor id: the `tk_ai` cookie, or an IP-based hash when
992 * proxy tracking is enabled. Returns null otherwise so the caller skips the event.
993 *
994 * We never mint a new id here: attributing an event to a brand-new id creates a
995 * throwaway one-event "visitor" (mostly cookie-less crawlers) that inflates session
996 * counts. Real browsers already have a `tk_ai` cookie by the time an event fires.
997 *
998 * @return string|null Stable visitor id, or null when none is available.
999 */
1000 private static function get_visitor_id() {
1001 // Return cached result if available.
1002 if ( null !== self::$cached_visitor_id ) {
1003 return self::$cached_visitor_id;
1004 }
1005
1006 // Prefer the tk_ai cookie if present.
1007 if ( ! empty( $_COOKIE['tk_ai'] ) ) {
1008 self::$cached_visitor_id = sanitize_text_field( wp_unslash( $_COOKIE['tk_ai'] ) );
1009 return self::$cached_visitor_id;
1010 }
1011
1012 // Cron and WP-CLI have no real visitor; never attribute background activity to one.
1013 if ( ( defined( 'DOING_CRON' ) && DOING_CRON )
1014 || ( defined( 'WP_CLI' ) && WP_CLI )
1015 ) {
1016 return null;
1017 }
1018
1019 // Proxy tracking provides a stable id from daily_salt + domain + ip + user_agent.
1020 if ( Features::is_proxy_tracking_enabled() ) {
1021 self::$cached_visitor_id = self::get_ip_based_visitor_id();
1022 return self::$cached_visitor_id;
1023 }
1024
1025 // No stable id arrived with the request. Do not mint one (see method doc).
1026 return null;
1027 }
1028
1029 /**
1030 * Get the user's IP address.
1031 *
1032 * @return string The user's IP address. An empty string if no valid IP address is found.
1033 */
1034 private static function get_user_ip_address() {
1035 // Return cached IP if available
1036 if ( null !== self::$cached_ip ) {
1037 return self::$cached_ip;
1038 }
1039
1040 $ip_headers = array(
1041 'HTTP_CF_CONNECTING_IP', // Cloudflare specific header.
1042 'HTTP_X_FORWARDED_FOR',
1043 'REMOTE_ADDR',
1044 'HTTP_CLIENT_IP',
1045 );
1046
1047 foreach ( $ip_headers as $header ) {
1048 if ( isset( $_SERVER[ $header ] ) ) {
1049 $ip_list = explode( ',', wp_unslash( $_SERVER[ $header ] ) ); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
1050 foreach ( $ip_list as $ip_candidate ) {
1051 $ip_candidate = trim( $ip_candidate );
1052 if ( filter_var(
1053 $ip_candidate,
1054 FILTER_VALIDATE_IP,
1055 array( FILTER_FLAG_NO_RES_RANGE, FILTER_FLAG_IPV6 )
1056 ) ) {
1057 // Cache the resolved IP
1058 self::$cached_ip = $ip_candidate;
1059 return self::$cached_ip;
1060 }
1061 }
1062 }
1063 }
1064
1065 // Cache empty result
1066 self::$cached_ip = '';
1067 return self::$cached_ip;
1068 }
1069
1070 /**
1071 * Get IP-based visitor ID for proxy tracking mode.
1072 *
1073 * @return string|null
1074 */
1075 private static function get_ip_based_visitor_id() {
1076 $ip = self::get_user_ip_address();
1077 if ( empty( $ip ) ) {
1078 return null;
1079 }
1080
1081 $salt = self::get_daily_salt();
1082 $url_parts = wp_parse_url( home_url() );
1083 $domain = $url_parts['host'] ?? '';
1084 $user_agent = sanitize_text_field( wp_unslash( $_SERVER['HTTP_USER_AGENT'] ?? '' ) );
1085
1086 // Create hash from: daily_salt + domain + ip + user_agent
1087 $hash_input = $salt . $domain . $ip . $user_agent;
1088
1089 return substr( hash( 'sha256', $hash_input ), 0, 16 );
1090 }
1091
1092 /**
1093 * Get or generate daily salt for visitor ID hashing.
1094 * Creates a new salt value each day (UTC) for privacy protection.
1095 *
1096 * @return string The daily salt.
1097 */
1098 private static function get_daily_salt() {
1099 $today = gmdate( 'Y-m-d' ); // UTC date
1100
1101 $salt_data = get_option( self::DAILY_SALT_OPTION );
1102
1103 // Check if salt exists and is still valid for today
1104 if (
1105 is_array( $salt_data )
1106 && isset( $salt_data['date'] )
1107 && isset( $salt_data['salt'] )
1108 && $salt_data['date'] === $today
1109 ) {
1110 return $salt_data['salt'];
1111 }
1112
1113 // Generate new salt for today
1114 $new_salt = wp_generate_password( 32, false );
1115
1116 // Store salt with date (no expiration time needed)
1117 $salt_data = array(
1118 'date' => $today,
1119 'salt' => $new_salt,
1120 );
1121
1122 update_option( self::DAILY_SALT_OPTION, $salt_data );
1123 return $new_salt;
1124 }
1125
1126 /**
1127 * Get the device type for the current request.
1128 *
1129 * Uses Jetpack Device Detection to distinguish between mobile phones, tablets, and desktop devices.
1130 *
1131 * @return string 'mobile' for phones, 'tablet' for tablets, 'desktop' otherwise.
1132 */
1133 private static function get_device_type() {
1134 if ( Device_Detection::is_phone() ) {
1135 return 'mobile';
1136 }
1137
1138 if ( Device_Detection::is_tablet() ) {
1139 return 'tablet';
1140 }
1141
1142 return 'desktop';
1143 }
1144 }
1145