PluginProbe
Jetpack – WP Security, Backup, Speed, & Growth / 16.3-beta
Jetpack – WP Security, Backup, Speed, & Growth v16.3-beta
16.3 16.3-beta 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 All 508 releases
← All changes | jetpack_vendor/automattic/woocommerce-analytics/src/class-wc-analytics-tracking.php +1144 -0 16.2-beta → 16.3-beta View file →
@@ -1,0 +1,1144 @@
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 +}