Commit af368be167d for woocommerce
commit af368be167d84a6007e39016b3d9907688a17004
Author: Chi-Hsuan Huang <chihsuan.tw@gmail.com>
Date: Tue Oct 20 15:05:16 2026 +0800
Skip server-side analytics events for shoppers who denied statistics consent (#70008)
diff --git a/packages/php/woocommerce-analytics/changelog/fix-analytics-server-side-consent-deny b/packages/php/woocommerce-analytics/changelog/fix-analytics-server-side-consent-deny
new file mode 100644
index 00000000000..e9ca5bfaa42
--- /dev/null
+++ b/packages/php/woocommerce-analytics/changelog/fix-analytics-server-side-consent-deny
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fixed
+
+Stop recording server-side events for shoppers who denied statistics consent with a consent plugin that works in the browser.
diff --git a/packages/php/woocommerce-analytics/src/class-consent-manager.php b/packages/php/woocommerce-analytics/src/class-consent-manager.php
index 332c8ffeeb5..777e931a1dc 100644
--- a/packages/php/woocommerce-analytics/src/class-consent-manager.php
+++ b/packages/php/woocommerce-analytics/src/class-consent-manager.php
@@ -64,6 +64,27 @@ class Consent_Manager {
return true;
}
+ // Without a consent type the WP Consent API grants every category, even after the shopper denied statistics in the browser.
+ if ( function_exists( 'wp_get_consent_type' ) && ! \wp_get_consent_type() && self::is_statistics_consent_denied() ) {
+ return false;
+ }
+
return \wp_has_consent( self::WP_CONSENT_API_STATISTICS_TYPE );
}
+
+ /**
+ * Check if the shopper denied statistics consent, as recorded in the WP Consent API consent cookie.
+ *
+ * @return bool
+ */
+ private static function is_statistics_consent_denied() {
+ $prefix = apply_filters( 'wp_consent_cookie_prefix', 'wp_consent' ); // phpcs:ignore WooCommerce.Commenting.CommentHooks.MissingHookComment -- Reads a WP Consent API filter.
+ if ( ! is_string( $prefix ) ) {
+ return false;
+ }
+
+ $cookie_name = $prefix . '_' . self::WP_CONSENT_API_STATISTICS_TYPE;
+
+ return isset( $_COOKIE[ $cookie_name ] ) && 'deny' === $_COOKIE[ $cookie_name ];
+ }
}
diff --git a/packages/php/woocommerce-analytics/tests/php/Consent_Manager_Test.php b/packages/php/woocommerce-analytics/tests/php/Consent_Manager_Test.php
new file mode 100644
index 00000000000..c3bc9905eb9
--- /dev/null
+++ b/packages/php/woocommerce-analytics/tests/php/Consent_Manager_Test.php
@@ -0,0 +1,120 @@
+<?php
+/**
+ * Tests for Consent_Manager when a consent plugin sets the consent type only in the browser.
+ *
+ * @package automattic/woocommerce-analytics
+ */
+
+namespace Automattic\Woocommerce_Analytics;
+
+use WorDBless\BaseTestCase;
+
+/**
+ * Tests that server-side tracking respects a shopper's statistics choice when PHP sees no consent type.
+ */
+class Consent_Manager_Test extends BaseTestCase {
+
+ /**
+ * Remove the consent cookies so each test starts without a choice.
+ */
+ public function tear_down(): void {
+ unset( $_COOKIE['wp_consent_statistics'], $_COOKIE['custom_statistics'], $_COOKIE['tk_ai'] );
+ parent::tear_down();
+ }
+
+ /**
+ * Test that a shopper who denied statistics has no consent, even though PHP sees no consent type.
+ *
+ * @runInSeparateProcess
+ * @preserveGlobalState disabled
+ */
+ public function test_denies_consent_when_the_shopper_denied_statistics_without_a_consent_type(): void {
+ require_once __DIR__ . '/mocks/wp-consent-api-cookie-functions.php';
+ $_COOKIE['wp_consent_statistics'] = 'deny';
+
+ $this->assertFalse( Consent_Manager::has_analytics_consent() );
+ }
+
+ /**
+ * Test that a shopper who has not chosen still has consent without a consent type, as the WP Consent API grants.
+ *
+ * @runInSeparateProcess
+ * @preserveGlobalState disabled
+ */
+ public function test_grants_consent_without_a_consent_type_when_the_shopper_has_not_chosen(): void {
+ require_once __DIR__ . '/mocks/wp-consent-api-cookie-functions.php';
+
+ $this->assertTrue( Consent_Manager::has_analytics_consent() );
+ }
+
+ /**
+ * Test that the denied choice is read from the consent cookie under a filtered prefix.
+ *
+ * @runInSeparateProcess
+ * @preserveGlobalState disabled
+ */
+ public function test_denies_consent_under_a_filtered_consent_cookie_prefix(): void {
+ require_once __DIR__ . '/mocks/wp-consent-api-cookie-functions.php';
+ add_filter( 'wp_consent_cookie_prefix', fn() => 'custom' );
+ $_COOKIE['custom_statistics'] = 'deny';
+
+ $this->assertFalse( Consent_Manager::has_analytics_consent() );
+ }
+
+ /**
+ * Test that with a consent type set, the decision stays with wp_has_consent() and its filter.
+ *
+ * @runInSeparateProcess
+ * @preserveGlobalState disabled
+ */
+ public function test_leaves_the_decision_to_wp_has_consent_when_a_consent_type_is_set(): void {
+ require_once __DIR__ . '/mocks/wp-consent-api-cookie-functions.php';
+ add_filter( 'wp_get_consent_type', fn() => 'optin' );
+ add_filter( 'wp_has_consent', '__return_true' );
+ $_COOKIE['wp_consent_statistics'] = 'deny';
+
+ $this->assertTrue( Consent_Manager::has_analytics_consent() );
+ }
+
+ /**
+ * Test that a server-side event is recorded for a shopper with a visitor id who has not chosen.
+ *
+ * @runInSeparateProcess
+ * @preserveGlobalState disabled
+ */
+ public function test_record_event_records_a_shopper_who_has_not_chosen(): void {
+ require_once __DIR__ . '/mocks/wp-consent-api-cookie-functions.php';
+ $_COOKIE['tk_ai'] = 'test-visitor-id-1234567890ab';
+
+ WC_Analytics_Tracking::record_event( 'add_to_cart' );
+
+ $this->assertCount( 1, $this->get_pixel_batch_queue() );
+ }
+
+ /**
+ * Test that a server-side event is skipped for a shopper who withdrew statistics consent but kept their `tk_ai` cookie.
+ *
+ * @runInSeparateProcess
+ * @preserveGlobalState disabled
+ */
+ public function test_record_event_skips_a_shopper_who_denied_statistics(): void {
+ require_once __DIR__ . '/mocks/wp-consent-api-cookie-functions.php';
+ $_COOKIE['tk_ai'] = 'test-visitor-id-1234567890ab';
+ $_COOKIE['wp_consent_statistics'] = 'deny';
+
+ WC_Analytics_Tracking::record_event( 'add_to_cart' );
+
+ $this->assertSame( array(), $this->get_pixel_batch_queue() );
+ }
+
+ /**
+ * Read the pixels queued for shutdown delivery.
+ *
+ * @return array
+ */
+ private function get_pixel_batch_queue(): array {
+ $property = ( new \ReflectionClass( WC_Analytics_Tracking::class ) )->getProperty( 'pixel_batch_queue' );
+ $property->setAccessible( true );
+ return $property->getValue();
+ }
+}
diff --git a/packages/php/woocommerce-analytics/tests/php/mocks/wp-consent-api-cookie-functions.php b/packages/php/woocommerce-analytics/tests/php/mocks/wp-consent-api-cookie-functions.php
new file mode 100644
index 00000000000..43230351ef8
--- /dev/null
+++ b/packages/php/woocommerce-analytics/tests/php/mocks/wp-consent-api-cookie-functions.php
@@ -0,0 +1,41 @@
+<?php
+/**
+ * Stand-in for the WP Consent API plugin that reads the consent cookie as WP Consent API 2.0 does. Load it only in a separate process.
+ *
+ * @package automattic/woocommerce-analytics
+ */
+
+if ( ! function_exists( 'wp_get_consent_type' ) ) {
+ /**
+ * Mock of the WP Consent API consent type, which consent plugins set through the same filter.
+ *
+ * @return string
+ */
+ function wp_get_consent_type() { // phpcs:ignore Universal.Files.SeparateFunctionsFromOO.Mixed
+ return apply_filters( 'wp_get_consent_type', '' );
+ }
+}
+
+if ( ! function_exists( 'wp_has_consent' ) ) {
+ /**
+ * Mirrors WP Consent API 2.0's wp_has_consent(), which grants every category when no consent type is set.
+ *
+ * @param string $category Consent category.
+ * @return bool
+ */
+ function wp_has_consent( $category ) { // phpcs:ignore Universal.Files.SeparateFunctionsFromOO.Mixed
+ $consent_type = wp_get_consent_type();
+ $cookie_name = apply_filters( 'wp_consent_cookie_prefix', 'wp_consent' ) . '_' . $category;
+ $cookie = $_COOKIE[ $cookie_name ] ?? null; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput
+
+ if ( ! $consent_type ) {
+ $has_consent = true;
+ } elseif ( false !== strpos( $consent_type, 'optout' ) && null === $cookie ) {
+ $has_consent = true;
+ } else {
+ $has_consent = 'allow' === $cookie;
+ }
+
+ return apply_filters( 'wp_has_consent', $has_consent, $category, null );
+ }
+}