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