Commit e0eb4174ef3 for woocommerce
commit e0eb4174ef3154bcbd15302a70c2d8085ea34a70
Author: Michal Iwanow <4765119+mcliwanow@users.noreply.github.com>
Date: Mon Oct 26 09:56:51 2026 +0100
Add a per-attempt id to the WooCommerce.com connect flow (#70133)
* Add a funnel id to WooCommerce.com connect tracking
Each connect attempt now gets a UUID that travels inside redirect_uri to
WooCommerce.com and back, so the connect, connected and cancelled events
can be joined with the WooCommerce.com side of the flow. The id is
passed to woocommerce_helper_connect_start as a new argument and sent
in the oauth/access_token body.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Read the return funnel id in one place in extensions tracking
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Restore $_GET after the funnel id tests
Also mark validate_funnel_id() internal and note the new parameter on
track_helper_connection_start().
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Remove test hooks after the funnel id tests and fix docblock typos
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Pass the funnel id to the helper return hooks and drop the onboarding path
Tracking now gets the id from woocommerce_helper_connected / _denied instead
of reading $_GET again. The REST onboarding endpoints have no caller in core
and never sent the id back, so they're left as they were.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
diff --git a/plugins/woocommerce/changelog/add-wccom-3109-connect-funnel-id b/plugins/woocommerce/changelog/add-wccom-3109-connect-funnel-id
new file mode 100644
index 00000000000..d1461056d48
--- /dev/null
+++ b/plugins/woocommerce/changelog/add-wccom-3109-connect-funnel-id
@@ -0,0 +1,4 @@
+Significance: patch
+Type: dev
+Comment: Add a per-attempt id to WooCommerce.com connect usage tracking; no merchant-facing change.
+
diff --git a/plugins/woocommerce/includes/admin/helper/class-wc-helper-api.php b/plugins/woocommerce/includes/admin/helper/class-wc-helper-api.php
index 4cff1402619..1258d789902 100644
--- a/plugins/woocommerce/includes/admin/helper/class-wc-helper-api.php
+++ b/plugins/woocommerce/includes/admin/helper/class-wc-helper-api.php
@@ -33,6 +33,21 @@ class WC_Helper_API {
self::$api_base = apply_filters( 'woocommerce_helper_api_base', 'https://woocommerce.com/wp-json/helper/1.0' );
}
+ /**
+ * Return the connect funnel id if it is valid, or an empty string otherwise.
+ *
+ * The id ties the events of one connect attempt together for analytics; it never authorizes anything.
+ *
+ * @internal
+ * @since 11.4.0
+ *
+ * @param mixed $funnel_id Candidate id.
+ * @return string
+ */
+ public static function validate_funnel_id( $funnel_id ): string {
+ return is_string( $funnel_id ) && preg_match( '/^[A-Za-z0-9_-]{1,64}$/D', $funnel_id ) ? $funnel_id : '';
+ }
+
/**
* Perform an HTTP request to the Helper API.
*
diff --git a/plugins/woocommerce/includes/admin/helper/class-wc-helper.php b/plugins/woocommerce/includes/admin/helper/class-wc-helper.php
index 2b976c12760..afdd6d39f58 100644
--- a/plugins/woocommerce/includes/admin/helper/class-wc-helper.php
+++ b/plugins/woocommerce/includes/admin/helper/class-wc-helper.php
@@ -1045,11 +1045,13 @@ class WC_Helper {
wp_die( 'Could not verify nonce' );
}
+ $funnel_id = wp_generate_uuid4();
$redirect_url_args = array(
'page' => self::get_source_page(),
'section' => 'helper',
'wc-helper-return' => 1,
'wc-helper-nonce' => wp_create_nonce( 'connect' ),
+ 'funnel_id' => $funnel_id,
);
if ( isset( $_GET['install'] ) ) {
@@ -1094,8 +1096,12 @@ class WC_Helper {
/**
* Fires when the Helper connection process is initiated.
+ *
+ * @since 11.4.0 Added the `$funnel_id` parameter.
+ *
+ * @param string $funnel_id Id of this connect attempt, carried through WooCommerce.com in the redirect URI.
*/
- do_action( 'woocommerce_helper_connect_start' );
+ do_action( 'woocommerce_helper_connect_start', $funnel_id );
// Ignore all previously dismissed connected notices.
delete_metadata( 'user', 0, \Automattic\WooCommerce\Admin\PluginsHelper::DISMISS_CONNECT_NOTICE, '', true );
@@ -1136,12 +1142,18 @@ class WC_Helper {
wp_die( 'Something went wrong' );
}
+ $funnel_id = WC_Helper_API::validate_funnel_id( wp_unslash( $_GET['funnel_id'] ?? '' ) ); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
+
// Bail if the user clicked deny.
if ( ! empty( $_GET['deny'] ) ) {
/**
* Fires when the Helper connection process is denied/cancelled.
+ *
+ * @since 11.4.0 Added the `$funnel_id` parameter.
+ *
+ * @param string $funnel_id Id of this connect attempt, empty when the return URL has no valid one.
*/
- do_action( 'woocommerce_helper_denied' );
+ do_action( 'woocommerce_helper_denied', $funnel_id );
wp_safe_redirect(
self::get_helper_redirect_url(
@@ -1160,15 +1172,21 @@ class WC_Helper {
wp_die( 'Something went wrong' );
}
+ $access_token_body = array(
+ 'request_token' => wp_unslash( $_GET['request_token'] ), // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
+ 'home_url' => home_url(),
+ );
+
+ if ( '' !== $funnel_id ) {
+ $access_token_body['funnel_id'] = $funnel_id;
+ }
+
// Obtain an access token.
$request = WC_Helper_API::post(
'oauth/access_token',
array(
'timeout' => 30,
- 'body' => array(
- 'request_token' => wp_unslash( $_GET['request_token'] ), // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
- 'home_url' => home_url(),
- ),
+ 'body' => $access_token_body,
)
);
@@ -1189,8 +1207,12 @@ class WC_Helper {
/**
* Fires when the Helper connection process has completed successfully.
+ *
+ * @since 11.4.0 Added the `$funnel_id` parameter.
+ *
+ * @param string $funnel_id Id of this connect attempt, empty when the return URL has no valid one.
*/
- do_action( 'woocommerce_helper_connected' );
+ do_action( 'woocommerce_helper_connected', $funnel_id );
// Enable tracking when connected.
if ( class_exists( 'WC_Tracker' ) ) {
diff --git a/plugins/woocommerce/includes/tracks/events/class-wc-extensions-tracking.php b/plugins/woocommerce/includes/tracks/events/class-wc-extensions-tracking.php
index 897af313d68..973a3b0eba8 100644
--- a/plugins/woocommerce/includes/tracks/events/class-wc-extensions-tracking.php
+++ b/plugins/woocommerce/includes/tracks/events/class-wc-extensions-tracking.php
@@ -72,24 +72,36 @@ class WC_Extensions_Tracking {
}
/**
- * Send a Tracks even when a Helper connection process is initiated.
+ * Send a Tracks event when a Helper connection process is initiated.
+ *
+ * @since 11.4.0 Added the `$funnel_id` parameter.
+ *
+ * @param string $funnel_id Id of the connect attempt.
*/
- public function track_helper_connection_start() {
- WC_Tracks::record_event( 'extensions_subscriptions_connect' );
+ public function track_helper_connection_start( $funnel_id = '' ) {
+ WC_Tracks::record_event( 'extensions_subscriptions_connect', $this->get_funnel_id_properties( $funnel_id ) );
}
/**
- * Send a Tracks even when a Helper connection process is cancelled.
+ * Send a Tracks event when a Helper connection process is cancelled.
+ *
+ * @since 11.4.0 Added the `$funnel_id` parameter.
+ *
+ * @param string $funnel_id Id of the connect attempt.
*/
- public function track_helper_connection_cancelled() {
- WC_Tracks::record_event( 'extensions_subscriptions_cancelled' );
+ public function track_helper_connection_cancelled( $funnel_id = '' ) {
+ WC_Tracks::record_event( 'extensions_subscriptions_cancelled', $this->get_funnel_id_properties( $funnel_id ) );
}
/**
- * Send a Tracks even when a Helper connection process completed successfully.
+ * Send a Tracks event when a Helper connection process completed successfully.
+ *
+ * @since 11.4.0 Added the `$funnel_id` parameter.
+ *
+ * @param string $funnel_id Id of the connect attempt.
*/
- public function track_helper_connection_complete() {
- $properties = array();
+ public function track_helper_connection_complete( $funnel_id = '' ) {
+ $properties = $this->get_funnel_id_properties( $funnel_id );
if ( ! empty( $_GET['utm_source'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended
$properties['utm_source'] = wc_clean( wp_unslash( $_GET['utm_source'] ) ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended
@@ -103,14 +115,26 @@ class WC_Extensions_Tracking {
}
/**
- * Send a Tracks even when a Helper has been disconnected.
+ * Build the funnel_id event property, omitted when the id is missing or invalid.
+ *
+ * @param mixed $funnel_id Candidate id passed by the hook.
+ * @return array
+ */
+ private function get_funnel_id_properties( $funnel_id ): array {
+ $funnel_id = empty( $funnel_id ) ? '' : WC_Helper_API::validate_funnel_id( $funnel_id );
+
+ return '' === $funnel_id ? array() : array( 'funnel_id' => $funnel_id );
+ }
+
+ /**
+ * Send a Tracks event when a Helper has been disconnected.
*/
public function track_helper_disconnected() {
WC_Tracks::record_event( 'extensions_subscriptions_disconnect' );
}
/**
- * Send a Tracks even when Helper subscriptions are refreshed.
+ * Send a Tracks event when Helper subscriptions are refreshed.
*/
public function track_helper_subscriptions_refresh() {
WC_Tracks::record_event( 'extensions_subscriptions_update' );
diff --git a/plugins/woocommerce/tests/php/includes/admin/helper/class-wc-helper-test.php b/plugins/woocommerce/tests/php/includes/admin/helper/class-wc-helper-test.php
index fb941cd8027..19238b11faf 100644
--- a/plugins/woocommerce/tests/php/includes/admin/helper/class-wc-helper-test.php
+++ b/plugins/woocommerce/tests/php/includes/admin/helper/class-wc-helper-test.php
@@ -8,11 +8,19 @@ use Automattic\Jetpack\Constants;
*/
class WC_Helper_Test extends \WC_Unit_Test_Case {
+ /**
+ * $_GET as it was before the test, since the WordPress test case doesn't restore it.
+ *
+ * @var array
+ */
+ private $original_get = array();
+
/**
* Set up before each test.
*/
public function setUp(): void {
parent::setUp();
+ $this->original_get = $_GET; // phpcs:ignore WordPress.Security.NonceVerification.Recommended
$this->cleanup_helper_transients();
WC_Helper::flush_local_woo_products_cache();
}
@@ -23,7 +31,7 @@ class WC_Helper_Test extends \WC_Unit_Test_Case {
public function tearDown(): void {
$this->cleanup_helper_transients();
$this->cleanup_auto_update_state();
- unset( $_GET['page'] );
+ $_GET = $this->original_get;
parent::tearDown();
}
@@ -1425,4 +1433,119 @@ class WC_Helper_Test extends \WC_Unit_Test_Case {
'Connecting should clear the update-check backoff'
);
}
+
+ /**
+ * @testdox validate_funnel_id should keep 1 to 64 letters, digits, dashes and underscores, and drop anything else.
+ *
+ * @testWith ["0a5b6c1e-2d3f-4a5b-8c9d-0e1f2a3b4c5d", "0a5b6c1e-2d3f-4a5b-8c9d-0e1f2a3b4c5d"]
+ * ["aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"]
+ * ["aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", ""]
+ * ["", ""]
+ * ["id with spaces", ""]
+ * ["valid-id\n", ""]
+ * [["valid-id"], ""]
+ *
+ * @param mixed $funnel_id Candidate id.
+ * @param string $expected Expected return value.
+ */
+ public function test_validate_funnel_id( $funnel_id, string $expected ): void {
+ $this->assertSame( $expected, WC_Helper_API::validate_funnel_id( $funnel_id ) );
+ }
+
+ /**
+ * @testdox Starting a connection should put one new funnel id in the redirect URI and pass the same id to the connect start hook.
+ */
+ public function test_connect_puts_funnel_id_in_redirect_uri_and_hook(): void {
+ $_GET = array(
+ 'page' => 'wc-addons',
+ 'section' => 'helper',
+ 'wc-helper-connect' => '1',
+ 'wc-helper-nonce' => wp_create_nonce( 'connect' ),
+ );
+
+ $request_token_redirect_uri = null;
+ $http_mock = static function ( $preempt, $args, $url ) use ( &$request_token_redirect_uri ) {
+ if ( false !== strpos( $url, 'oauth/request_token' ) ) {
+ $request_token_redirect_uri = $args['body']['redirect_uri'];
+ }
+ return array(
+ 'response' => array( 'code' => 200 ),
+ 'body' => wp_json_encode( 'test-secret' ),
+ );
+ };
+ add_filter( 'pre_http_request', $http_mock, 10, 3 );
+
+ $hook_funnel_id = null;
+ $hook_spy = static function ( $funnel_id ) use ( &$hook_funnel_id ) {
+ $hook_funnel_id = $funnel_id;
+ };
+ add_action( 'woocommerce_helper_connect_start', $hook_spy );
+
+ $authorize_url = null;
+ $redirect_stop = static function ( $location ) {
+ throw new \RuntimeException( $location ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped
+ };
+ add_filter( 'wp_redirect', $redirect_stop );
+
+ try {
+ WC_Helper::current_screen( (object) array( 'id' => 'woocommerce_page_wc-addons' ) );
+ } catch ( \RuntimeException $e ) {
+ $authorize_url = $e->getMessage();
+ } finally {
+ remove_filter( 'pre_http_request', $http_mock, 10 );
+ remove_action( 'woocommerce_helper_connect_start', $hook_spy );
+ remove_filter( 'wp_redirect', $redirect_stop );
+ }
+
+ $this->assertTrue( wp_is_uuid( (string) $hook_funnel_id, 4 ), 'The hook should receive a UUID v4 funnel id' );
+
+ wp_parse_str( (string) wp_parse_url( (string) $request_token_redirect_uri, PHP_URL_QUERY ), $request_token_args );
+ $this->assertSame( $hook_funnel_id, $request_token_args['funnel_id'] ?? null, 'The redirect URI sent to request_token should carry the funnel id' );
+
+ wp_parse_str( (string) wp_parse_url( (string) $authorize_url, PHP_URL_QUERY ), $authorize_args );
+ $this->assertSame( $request_token_redirect_uri, $authorize_args['redirect_uri'] ?? null, 'The authorize URL should carry the same redirect URI' );
+ $this->assertArrayNotHasKey( 'funnel_id', $authorize_args, 'The funnel id should travel only inside the redirect URI' );
+ }
+
+ /**
+ * @testdox Returning from WooCommerce.com should send a valid funnel id in the access token request, and none when it is invalid or missing.
+ *
+ * @testWith ["0a5b6c1e-2d3f-4a5b-8c9d-0e1f2a3b4c5d", "0a5b6c1e-2d3f-4a5b-8c9d-0e1f2a3b4c5d"]
+ * ["not valid!", null]
+ * [null, null]
+ *
+ * @param string|null $funnel_id Funnel id in the return URL, null when the URL has none.
+ * @param string|null $expected Funnel id expected in the request body, null when absent.
+ */
+ public function test_return_sends_funnel_id_in_access_token_request( ?string $funnel_id, ?string $expected ): void {
+ $_GET = array(
+ 'page' => 'wc-addons',
+ 'section' => 'helper',
+ 'wc-helper-return' => '1',
+ 'wc-helper-nonce' => wp_create_nonce( 'connect' ),
+ 'request_token' => 'test-request-token',
+ );
+ if ( null !== $funnel_id ) {
+ $_GET['funnel_id'] = $funnel_id;
+ }
+
+ $access_token_body = null;
+ $http_mock = static function ( $preempt, $args, $url ) use ( &$access_token_body ) {
+ if ( false !== strpos( $url, 'oauth/access_token' ) ) {
+ $access_token_body = $args['body'];
+ }
+ return array( 'response' => array( 'code' => 500 ) );
+ };
+ add_filter( 'pre_http_request', $http_mock, 10, 3 );
+
+ try {
+ WC_Helper::current_screen( (object) array( 'id' => 'woocommerce_page_wc-addons' ) );
+ $this->fail( 'A failed access token request should stop the return handler' );
+ } catch ( WPDieException $e ) {
+ $this->assertIsArray( $access_token_body, 'The access token request should have been sent' );
+ $this->assertSame( $expected, $access_token_body['funnel_id'] ?? null );
+ } finally {
+ remove_filter( 'pre_http_request', $http_mock, 10 );
+ }
+ }
}
diff --git a/plugins/woocommerce/tests/php/includes/tracks/events/class-wc-extensions-tracking-test.php b/plugins/woocommerce/tests/php/includes/tracks/events/class-wc-extensions-tracking-test.php
new file mode 100644
index 00000000000..f299640b438
--- /dev/null
+++ b/plugins/woocommerce/tests/php/includes/tracks/events/class-wc-extensions-tracking-test.php
@@ -0,0 +1,101 @@
+<?php
+declare( strict_types = 1 );
+
+/**
+ * Tests for the WC_Extensions_Tracking class.
+ */
+class WC_Extensions_Tracking_Test extends \WC_Unit_Test_Case {
+
+ /**
+ * The System Under Test.
+ *
+ * @var WC_Extensions_Tracking
+ */
+ private $sut;
+
+ /**
+ * Properties of the recorded Tracks events, keyed by event name.
+ *
+ * @var array<string, array>
+ */
+ private $recorded = array();
+
+ /**
+ * $_GET as it was before the test, since the WordPress test case doesn't restore it.
+ *
+ * @var array
+ */
+ private $original_get = array();
+
+ /**
+ * Set up test fixtures.
+ */
+ public function setUp(): void {
+ parent::setUp();
+ $this->original_get = $_GET; // phpcs:ignore WordPress.Security.NonceVerification.Recommended
+
+ include_once WC_ABSPATH . 'includes/tracks/events/class-wc-extensions-tracking.php';
+ $this->sut = new WC_Extensions_Tracking();
+
+ update_option( 'woocommerce_allow_tracking', 'yes' );
+ // WC_Tracks skips events for the test suite's own users.
+ wp_set_current_user( 0 );
+
+ $this->recorded = array();
+ add_filter( 'woocommerce_tracks_event_properties', array( $this, 'record_event_properties' ), 10, 2 );
+ }
+
+ /**
+ * Capture the properties of each recorded event.
+ *
+ * @param array $properties Event properties.
+ * @param string $event_name Event name.
+ * @return array
+ */
+ public function record_event_properties( $properties, $event_name ) {
+ $this->recorded[ $event_name ] = $properties;
+ return $properties;
+ }
+
+ /**
+ * Tear down test fixtures.
+ */
+ public function tearDown(): void {
+ $_GET = $this->original_get;
+ remove_filter( 'woocommerce_tracks_event_properties', array( $this, 'record_event_properties' ), 10 );
+ parent::tearDown();
+ }
+
+ /**
+ * @testdox The connect, connected and cancelled events should carry a valid funnel id from the hook, and omit a missing or malformed one.
+ *
+ * @testWith ["0a5b6c1e-2d3f-4a5b-8c9d-0e1f2a3b4c5d", "0a5b6c1e-2d3f-4a5b-8c9d-0e1f2a3b4c5d"]
+ * ["", null]
+ * ["not valid!", null]
+ *
+ * @param string $funnel_id Funnel id passed to the hook.
+ * @param string|null $expected Recorded funnel_id property, null when absent.
+ */
+ public function test_events_record_funnel_id_from_hook( string $funnel_id, ?string $expected ): void {
+ $this->sut->track_helper_connection_start( $funnel_id );
+ $this->sut->track_helper_connection_complete( $funnel_id );
+ $this->sut->track_helper_connection_cancelled( $funnel_id );
+
+ foreach ( array( 'connect', 'connected', 'cancelled' ) as $event ) {
+ $event_name = 'wcadmin_extensions_subscriptions_' . $event;
+ $this->assertArrayHasKey( $event_name, $this->recorded, "The $event event should be recorded" );
+ $this->assertSame( $expected, $this->recorded[ $event_name ]['funnel_id'] ?? null, "Unexpected funnel_id on the $event event" );
+ }
+ }
+
+ /**
+ * @testdox The connected event should keep its utm properties next to the funnel id.
+ */
+ public function test_connected_event_keeps_utm_properties(): void {
+ $_GET = array( 'utm_source' => 'pu' );
+
+ $this->sut->track_helper_connection_complete( '0a5b6c1e-2d3f-4a5b-8c9d-0e1f2a3b4c5d' );
+
+ $this->assertSame( 'pu', $this->recorded['wcadmin_extensions_subscriptions_connected']['utm_source'] ?? null );
+ }
+}