Commit 5aad72e280e for woocommerce

commit 5aad72e280ed084be9969946d569860cea596c90
Author: Chi-Hsuan Huang <chihsuan.tw@gmail.com>
Date:   Mon May 11 10:54:47 2026 +0800

    Fix WooCommerce Analytics session inflation from cookie-less events (#64686)

    * fix: skip analytics events from cookie-less contexts

    WC_Analytics_Tracking::record_event() now bails when no tk_ai cookie
    is available and the request can't persist one (REST/XMLRPC/cron/CLI/
    post-headers). Previously get_visitor_id() minted a fresh random id per
    call in those contexts, producing one-event "sessions" in Nosara that
    inflated session counts (Subscriptions renewals, mobile apps, payment
    webhooks, headless storefronts, etc.).

    Restores pre-Oct 23 behavior for cookie-less paths only; real browser
    visits still mint and persist a tk_ai on first request.

    * lint: exclude Yoda condition and inline comment punctuation rules

    Disables two stylistic WPCS sniffs in the woocommerce-analytics package:

    - WordPress.PHP.YodaConditions
    - Squiz.Commenting.InlineComment.InvalidEndChar

    These rules don't catch real bugs and add friction to PRs without
    improving code quality. The package's existing code style already
    diverges from them in several places.

    * review: tk_ai readable from JS, clearer test name

    - get_visitor_id() now writes tk_ai with httponly=false. The cookie is
      an anonymous visitor identifier, not a credential, and keeping it
      readable from document.cookie lets the _wca legacy library and the
      client-side analytics module see the same value the server wrote —
      preventing split visitor IDs across server and JS reads.
    - Rename test_record_event_proceeds_when_cookie_is_present to
      test_get_visitor_id_returns_cookie_value_in_rest_context to reflect
      what the test actually asserts (it exercises get_visitor_id() via
      reflection, not record_event()).

    * review: assert no pixel emission in skip test, fix tear_down note

    - Strengthen test_record_event_skips_rest_request_without_cookie: hook
      pre_http_request to capture pixel.wp.com calls and inspect the
      pixel_batch_queue static via reflection. record_event() returns true
      both on skip and on success, so the return value alone is not enough
      evidence — assert there is also no HTTP egress and no batched entry.
    - Reset the pixel_batch_queue static between tests.
    - Rewrite tear_down() docblock: PHP constants set in earlier tests
      (REST_REQUEST, etc.) cannot be undefined and *will* leak for the
      remainder of the process. Document the actual behavior and the
      recommended workaround (@runInSeparateProcess).

    * Skip cron and WP-CLI before the proxy-tracking branch

    Cron and CLI requests have no real client context — no remote IP, no
    User-Agent — so even with proxy tracking enabled
    get_ip_based_visitor_id() collapses to a constant hash that all
    background activity gets attributed to (a single phantom "user"
    swallowing every cron/CLI event). Skip these contexts unconditionally
    before the proxy check, while keeping the proxy escape-hatch intact for
    REST/XMLRPC/post-headers requests where the client's IP and UA are
    actually present.

    * review: bail on setcookie failure, harden bot test, simplify comments

    - get_visitor_id() now checks setcookie() return; on failure, returns null
      without caching so record_event() skips. A non-persisted id would still
      fragment future sessions, defeating the guard.
    - Move test_record_event_skips_bots to be the FIRST test method, inject a
      tk_ai cookie, and add pre_http_request + pixel_batch_queue assertions.
      Without this the cookie-less guard masks the bot path once REST_REQUEST
      leaks from a prior test, so the test could pass even with the bot check
      removed.
    - Update record_event() docblock to cover deliberate skip cases.
    - Collapse multi-line comments to one line each, keeping only the WHY.

    * fix: drop unreachable setcookie failure branch in WC Analytics visitor ID

diff --git a/packages/php/woocommerce-analytics/changelog/fix-skip-cookieless-events b/packages/php/woocommerce-analytics/changelog/fix-skip-cookieless-events
new file mode 100644
index 00000000000..cf899002295
--- /dev/null
+++ b/packages/php/woocommerce-analytics/changelog/fix-skip-cookieless-events
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fixed
+
+Skip server-side analytics events from cookie-less contexts (REST API, XMLRPC, cron, WP-CLI) to prevent inflated session counts.
diff --git a/packages/php/woocommerce-analytics/phpcs.xml b/packages/php/woocommerce-analytics/phpcs.xml
index fd9d2700f6c..4a3e6295eac 100644
--- a/packages/php/woocommerce-analytics/phpcs.xml
+++ b/packages/php/woocommerce-analytics/phpcs.xml
@@ -1,6 +1,11 @@
 <?xml version="1.0"?>
 <ruleset name="WooCommerce Analytics Coding Standards">
-	<rule ref="WordPress"/>
+	<rule ref="WordPress">
+		<!-- Yoda conditions are not enforced in this package. -->
+		<exclude name="WordPress.PHP.YodaConditions"/>
+		<!-- Inline comments are not required to end with a full stop. -->
+		<exclude name="Squiz.Commenting.InlineComment.InvalidEndChar"/>
+	</rule>

 	<!-- Define files and folders to scan -->
 	<file>src</file>
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 dca525154ad..836ab57b82d 100644
--- a/packages/php/woocommerce-analytics/src/class-wc-analytics-tracking.php
+++ b/packages/php/woocommerce-analytics/src/class-wc-analytics-tracking.php
@@ -73,10 +73,11 @@ class WC_Analytics_Tracking {
 	 * @param string $event_name The name of the event.
 	 * @param array  $event_properties Custom properties to send with the event.
 	 *
-	 * @return bool|WP_Error True for success or WP_Error if the event pixel could not be fired.
+	 * @return bool|WP_Error True on emit or deliberate skip (no consent, bot UA,
+	 *                       or cookie-less context); WP_Error if pixel firing failed.
 	 */
 	public static function record_event( $event_name, $event_properties = array() ) {
-		// Check consent before recording any event
+		// Check consent before recording any event.
 		if ( ! Consent_Manager::has_analytics_consent() ) {
 			return true; // Skip recording.
 		}
@@ -86,6 +87,11 @@ class WC_Analytics_Tracking {
 			return true;
 		}

+		// Skip cookie-less contexts that cannot persist a stable visitor id; fresh random ids fragment sessions.
+		if ( empty( self::get_visitor_id() ) ) {
+			return true;
+		}
+
 		$prefixed_event_name = self::PREFIX . $event_name;
 		$properties          = self::get_properties( $prefixed_event_name, $event_properties );

@@ -468,39 +474,50 @@ class WC_Analytics_Tracking {
 			return self::$cached_visitor_id;
 		}

-		// Fallback to IP-based visitor ID if proxy tracking is enabled.
+		// Cron and WP-CLI have no client IP or UA, so even proxy-tracking would collapse all background activity into one phantom user.
+		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.
 		if ( Features::is_proxy_tracking_enabled() ) {
 			self::$cached_visitor_id = self::get_ip_based_visitor_id();
 			return self::$cached_visitor_id;
 		}

-		// Generate a new anonId and try to save it in the browser's cookies.
-		// Note that base64-encoding an 18 character string generates a 24-character anon 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 ) );
 		}

-		self::$cached_visitor_id = base64_encode( $binary ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode
+		$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',
+			)
+		);

-		if ( ! headers_sent()
-			&& ! ( defined( 'REST_REQUEST' ) && REST_REQUEST )
-			&& ! ( defined( 'XMLRPC_REQUEST' ) && XMLRPC_REQUEST )
-		) {
-			setcookie(
-				'tk_ai',
-				self::$cached_visitor_id,
-				array(
-					'expires'  => time() + ( 365 * 24 * 60 * 60 ), // 1 year
-					'path'     => '/',
-					'domain'   => COOKIE_DOMAIN,
-					'secure'   => is_ssl(),
-					'httponly' => true,
-					'samesite' => 'Strict',
-				)
-			);
-		}
+		self::$cached_visitor_id = $new_visitor_id;
 		return self::$cached_visitor_id;
 	}

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
new file mode 100644
index 00000000000..8c124c64e02
--- /dev/null
+++ b/packages/php/woocommerce-analytics/tests/php/WC_Analytics_Tracking_Test.php
@@ -0,0 +1,162 @@
+<?php
+/**
+ * Tests for WC_Analytics_Tracking::record_event() cookie-less skip behavior.
+ *
+ * @package automattic/woocommerce-analytics
+ */
+
+namespace Automattic\Woocommerce_Analytics;
+
+use WorDBless\BaseTestCase;
+
+/**
+ * Tests for WC_Analytics_Tracking::record_event().
+ *
+ * Verifies that events fired from contexts that cannot persist a `tk_ai`
+ * cookie (REST/XMLRPC/cron/CLI) are skipped instead of producing fresh
+ * random anonymous ids that fragment downstream sessions.
+ */
+class WC_Analytics_Tracking_Test extends BaseTestCase {
+
+	/**
+	 * Reset cached static state and superglobals before each test.
+	 */
+	public function set_up(): void {
+		parent::set_up();
+		$this->reset_tracking_static_state();
+		unset( $_COOKIE['tk_ai'] );
+	}
+
+	/**
+	 * Reset cached static state after each test.
+	 *
+	 * Note: PHP constants like REST_REQUEST cannot be undefined once set, so
+	 * any constant a test defines leaks for the rest of the process. Tests
+	 * that depend on the absence of those constants must run first, or move
+	 * to a class annotated with @runInSeparateProcess.
+	 */
+	public function tear_down(): void {
+		$this->reset_tracking_static_state();
+		unset( $_COOKIE['tk_ai'] );
+		parent::tear_down();
+	}
+
+	/**
+	 * Use reflection to clear the `cached_visitor_id` and `pixel_batch_queue`
+	 * statics between tests so each case starts from a known-empty state.
+	 */
+	private function reset_tracking_static_state(): void {
+		$reflection = new \ReflectionClass( WC_Analytics_Tracking::class );
+
+		$visitor = $reflection->getProperty( 'cached_visitor_id' );
+		$visitor->setAccessible( true );
+		$visitor->setValue( null, null );
+
+		$queue = $reflection->getProperty( 'pixel_batch_queue' );
+		$queue->setAccessible( true );
+		$queue->setValue( null, array() );
+	}
+
+	/**
+	 * Read the current `pixel_batch_queue` static via reflection. Used to
+	 * verify whether a pixel was queued for shutdown delivery.
+	 *
+	 * @return array
+	 */
+	private function get_pixel_batch_queue(): array {
+		$reflection = new \ReflectionClass( WC_Analytics_Tracking::class );
+		$property   = $reflection->getProperty( 'pixel_batch_queue' );
+		$property->setAccessible( true );
+		return $property->getValue();
+	}
+
+	/**
+	 * Must run BEFORE any test defines REST_REQUEST: once leaked, the cookie-less guard
+	 * would skip on its own and mask a broken bot check. The injected tk_ai cookie is a
+	 * second belt so the bot path is the only thing that can produce the skip.
+	 */
+	public function test_record_event_skips_bots(): void {
+		$_SERVER['HTTP_USER_AGENT'] = 'Googlebot/2.1 (+http://www.google.com/bot.html)';
+		$_COOKIE['tk_ai']           = 'test-visitor-id-1234567890ab';
+
+		$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 );
+		unset( $_SERVER['HTTP_USER_AGENT'] );
+
+		$this->assertTrue( $result, 'record_event should skip bot traffic.' );
+		$this->assertCount( 0, $captured, 'No pixel.wp.com request should fire for bot UA.' );
+		$this->assertSame( array(), $this->get_pixel_batch_queue(), 'No pixel should be queued for bot UA.' );
+	}
+
+	/**
+	 * 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
+	 * here would fragment Nosara/Tracks sessions across cookie-less
+	 * integrations.
+	 *
+	 * Asserts both the skip return value AND the absence of any HTTP egress
+	 * (intercepted via `pre_http_request`) or queued batch entry — since
+	 * `record_event()` returns true both on skip and on successful emission,
+	 * the return value alone is not sufficient evidence of the skip.
+	 */
+	public function test_record_event_skips_rest_request_without_cookie(): void {
+		if ( ! defined( 'REST_REQUEST' ) ) {
+			define( 'REST_REQUEST', true );
+		}
+
+		$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 REST contexts.' );
+		$this->assertCount( 0, $captured, 'No pixel.wp.com request should fire when no tk_ai cookie is present in REST context.' );
+		$this->assertSame( array(), $this->get_pixel_batch_queue(), 'No pixel should be queued for batch when no tk_ai cookie is present in REST context.' );
+	}
+
+	/**
+	 * When the `tk_ai` cookie is present, get_visitor_id() should return its
+	 * value verbatim — even inside a REST request. This is the precondition
+	 * that lets record_event() proceed past the cookie-less skip guard for
+	 * real visitors whose action arrived via Store API, mobile app, or AJAX
+	 * with cookies forwarded.
+	 */
+	public function test_get_visitor_id_returns_cookie_value_in_rest_context(): void {
+		if ( ! defined( 'REST_REQUEST' ) ) {
+			define( 'REST_REQUEST', true );
+		}
+
+		$_COOKIE['tk_ai'] = 'test-visitor-id-1234567890ab';
+
+		$reflection = new \ReflectionClass( WC_Analytics_Tracking::class );
+		$method     = $reflection->getMethod( 'get_visitor_id' );
+		$method->setAccessible( true );
+		$visitor_id = $method->invoke( null );
+
+		$this->assertSame( 'test-visitor-id-1234567890ab', $visitor_id, 'Cookie value should be returned verbatim when present.' );
+	}
+}