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.' );
+ }
+}