Commit 365877fa9d0 for woocommerce
commit 365877fa9d03309b93240781192f4535256d9afb
Author: Chi-Hsuan Huang <chihsuan.tw@gmail.com>
Date: Wed Jun 17 16:12:46 2026 +0800
fix: skip analytics events from clients without a stable visitor id (#65792)
* fix: skip analytics events from clients without a stable visitor id
get_visitor_id() no longer mints a throwaway tk_ai for cookie-less requests; events without a pre-existing stable id are skipped. Stops UA-spoofing crawler traffic from inflating session counts. Completes the partial fix in #64686 (regression from Automattic/jetpack#45547).
* docs: simplify get_visitor_id method docblock
* test: correct cookie-less test docblocks for no-mint behavior
diff --git a/packages/php/woocommerce-analytics/changelog/fix-cookieless-event-session-inflation b/packages/php/woocommerce-analytics/changelog/fix-cookieless-event-session-inflation
new file mode 100644
index 00000000000..4a94147285a
--- /dev/null
+++ b/packages/php/woocommerce-analytics/changelog/fix-cookieless-event-session-inflation
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fixed
+
+Stop minting throwaway anonymous IDs for events from clients without a persisted visitor cookie (e.g. UA-spoofing crawlers hitting add-to-cart), which inflated session counts. Such events are now skipped instead.
diff --git a/packages/php/woocommerce-analytics/src/class-wc-analytics-tracking.php b/packages/php/woocommerce-analytics/src/class-wc-analytics-tracking.php
index 836ab57b82d..a56c1968027 100644
--- a/packages/php/woocommerce-analytics/src/class-wc-analytics-tracking.php
+++ b/packages/php/woocommerce-analytics/src/class-wc-analytics-tracking.php
@@ -87,7 +87,7 @@ class WC_Analytics_Tracking {
return true;
}
- // Skip cookie-less contexts that cannot persist a stable visitor id; fresh random ids fragment sessions.
+ // Skip events that arrive without a stable visitor id (e.g. no tk_ai cookie); see get_visitor_id().
if ( empty( self::get_visitor_id() ) ) {
return true;
}
@@ -458,9 +458,14 @@ class WC_Analytics_Tracking {
}
/**
- * Get the visitor id from the cookie or IP address (if proxy tracking is enabled).
+ * Get the existing stable visitor id: the `tk_ai` cookie, or an IP-based hash when
+ * proxy tracking is enabled. Returns null otherwise so the caller skips the event.
*
- * @return string|null
+ * We never mint a new id here: attributing an event to a brand-new id creates a
+ * throwaway one-event "visitor" (mostly cookie-less crawlers) that inflates session
+ * counts. Real browsers already have a `tk_ai` cookie by the time an event fires.
+ *
+ * @return string|null Stable visitor id, or null when none is available.
*/
private static function get_visitor_id() {
// Return cached result if available.
@@ -468,57 +473,27 @@ class WC_Analytics_Tracking {
return self::$cached_visitor_id;
}
- // Prefer tk_ai cookie if present.
+ // Prefer the tk_ai cookie if present.
if ( ! empty( $_COOKIE['tk_ai'] ) ) {
self::$cached_visitor_id = sanitize_text_field( wp_unslash( $_COOKIE['tk_ai'] ) );
return self::$cached_visitor_id;
}
- // Cron and WP-CLI have no client IP or UA, so even proxy-tracking would collapse all background activity into one phantom user.
+ // Cron and WP-CLI have no real visitor; never attribute background activity to one.
if ( ( defined( 'DOING_CRON' ) && DOING_CRON )
|| ( defined( 'WP_CLI' ) && WP_CLI )
) {
return null;
}
- // Proxy tracking provides a stable id from daily_salt + domain + ip + user_agent, even in REST/XMLRPC contexts.
+ // Proxy tracking provides a stable id from daily_salt + domain + ip + user_agent.
if ( Features::is_proxy_tracking_enabled() ) {
self::$cached_visitor_id = self::get_ip_based_visitor_id();
return self::$cached_visitor_id;
}
- // Only mint a new id when we can persist it in a cookie; otherwise it would be a single-use throwaway that fragments sessions.
- if ( headers_sent()
- || ( defined( 'REST_REQUEST' ) && REST_REQUEST )
- || ( defined( 'XMLRPC_REQUEST' ) && XMLRPC_REQUEST )
- ) {
- return null;
- }
-
- // Base64-encoding 18 random bytes produces a 24-character anon id.
- $binary = '';
- for ( $i = 0; $i < 18; ++$i ) {
- $binary .= chr( wp_rand( 0, 255 ) );
- }
-
- $new_visitor_id = base64_encode( $binary ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode
-
- // httponly=false is intentional: _wca and client-side analytics need to read the same value the server wrote.
- setcookie(
- 'tk_ai',
- $new_visitor_id,
- array(
- 'expires' => time() + ( 365 * 24 * 60 * 60 ), // 1 year
- 'path' => '/',
- 'domain' => COOKIE_DOMAIN,
- 'secure' => is_ssl(),
- 'httponly' => false,
- 'samesite' => 'Strict',
- )
- );
-
- self::$cached_visitor_id = $new_visitor_id;
- return self::$cached_visitor_id;
+ // No stable id arrived with the request. Do not mint one (see method doc).
+ return null;
}
/**
diff --git a/packages/php/woocommerce-analytics/tests/php/WC_Analytics_Tracking_Test.php b/packages/php/woocommerce-analytics/tests/php/WC_Analytics_Tracking_Test.php
index 8c124c64e02..c6bdeb821d6 100644
--- a/packages/php/woocommerce-analytics/tests/php/WC_Analytics_Tracking_Test.php
+++ b/packages/php/woocommerce-analytics/tests/php/WC_Analytics_Tracking_Test.php
@@ -101,6 +101,53 @@ class WC_Analytics_Tracking_Test extends BaseTestCase {
$this->assertSame( array(), $this->get_pixel_batch_queue(), 'No pixel should be queued for bot UA.' );
}
+ /**
+ * With no `tk_ai` cookie and proxy tracking off, get_visitor_id() must return null
+ * instead of minting a fresh id. Minting-and-attributing here was the Nov 2025
+ * regression that produced one-event "visitors" — overwhelmingly UA-spoofing crawler
+ * traffic that never persists a cookie — inflating Tracks session counts.
+ *
+ * Order-independent: the cookie-less path no longer branches on REST_REQUEST, and the
+ * method no longer sets a cookie server-side (real browsers get `tk_ai` client-side).
+ */
+ public function test_get_visitor_id_returns_null_for_non_rest_request_without_cookie(): void {
+ $reflection = new \ReflectionClass( WC_Analytics_Tracking::class );
+ $method = $reflection->getMethod( 'get_visitor_id' );
+ $method->setAccessible( true );
+
+ $this->assertNull(
+ $method->invoke( null ),
+ 'A freshly-minted id must not be returned for cookie-less non-REST requests.'
+ );
+ }
+
+ /**
+ * Companion behavioral assertion: record_event() emits/queues no pixel for a
+ * cookie-less request — the crawler path that inflated session counts. Order-independent
+ * (the skip no longer depends on REST_REQUEST).
+ */
+ public function test_record_event_skips_non_rest_request_without_cookie(): void {
+ $captured = array();
+ $filter = function ( $pre, $args, $url ) use ( &$captured ) {
+ if ( false !== strpos( $url, 'pixel.wp.com' ) ) {
+ $captured[] = $url;
+ }
+ return array(
+ 'response' => array( 'code' => 200 ),
+ 'body' => '',
+ );
+ };
+ add_filter( 'pre_http_request', $filter, 10, 3 );
+
+ $result = WC_Analytics_Tracking::record_event( 'add_to_cart' );
+
+ remove_filter( 'pre_http_request', $filter, 10 );
+
+ $this->assertTrue( $result, 'record_event should return true (skipped) for cookie-less non-REST contexts.' );
+ $this->assertCount( 0, $captured, 'No pixel.wp.com request should fire when no tk_ai cookie is present in a non-REST context.' );
+ $this->assertSame( array(), $this->get_pixel_batch_queue(), 'No pixel should be queued when no tk_ai cookie is present in a non-REST context.' );
+ }
+
/**
* record_event() should short-circuit (no pixel emitted) when called from
* a REST request that has no `tk_ai` cookie. Generating a one-shot id