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