Commit 341a24cf5ab for woocommerce

commit 341a24cf5ab98f55bb6b27214403f743a58317cd
Author: Chi-Hsuan Huang <chihsuan.tw@gmail.com>
Date:   Tue Oct 20 16:24:24 2026 +0800

    Fix nested array and object event props breaking the analytics pixel (#70009)

    * Fix nested array and object event props breaking the analytics pixel

    * Mark the shared prop flattener internal and test Pixel_Builder's nested values

diff --git a/packages/php/woocommerce-analytics/changelog/fix-wooa7s-2196-flatten-nested-props b/packages/php/woocommerce-analytics/changelog/fix-wooa7s-2196-flatten-nested-props
new file mode 100644
index 00000000000..8c68a987703
--- /dev/null
+++ b/packages/php/woocommerce-analytics/changelog/fix-wooa7s-2196-flatten-nested-props
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fixed
+
+Send nested arrays and objects from the event properties filter as JSON instead of breaking the tracking request.
diff --git a/packages/php/woocommerce-analytics/src/class-pixel-builder.php b/packages/php/woocommerce-analytics/src/class-pixel-builder.php
index 4dada0c38f0..eb5b7c6ebdf 100644
--- a/packages/php/woocommerce-analytics/src/class-pixel-builder.php
+++ b/packages/php/woocommerce-analytics/src/class-pixel-builder.php
@@ -171,46 +171,54 @@ class Pixel_Builder {
 	/**
 	 * Sanitize property values for URL serialization.
 	 *
-	 * Converts array values to appropriate formats to prevent http_build_query()
-	 * from creating bracket notation (e.g., prop[0], prop[1]) which violates
-	 * the property name regex.
+	 * Flattens array and object values so http_build_query() does not create
+	 * bracket notation (e.g., prop[0], prop[1]) which violates the property name regex.
 	 *
 	 * @param array $properties Event properties.
 	 * @return array Sanitized properties.
 	 */
 	private static function sanitize_property_values( $properties ) {
-		foreach ( $properties as $key => $value ) {
-			if ( ! is_array( $value ) ) {
-				continue;
-			}
+		return array_map( array( __CLASS__, 'flatten_property_value' ), $properties );
+	}

-			if ( empty( $value ) ) {
-				// Empty array becomes empty string.
-				$properties[ $key ] = '';
-				continue;
-			}
+	/**
+	 * Reduce one property value to the scalar that goes into the pixel URL.
+	 *
+	 * Indexed arrays of scalars become a comma-joined string; other arrays and objects become JSON.
+	 * Not URL-encoded here: http_build_query() encodes the whole URL, so encoding twice stores `%2F` in Tracks.
+	 *
+	 * @internal Shared with WC_Analytics_Tracking's payload budget; the format may change.
+	 *
+	 * @since 0.18.1
+	 *
+	 * @param mixed $value Property value.
+	 * @return mixed The scalar it serializes to: '' for an empty array or a value JSON cannot encode; scalars and null as-is.
+	 */
+	public static function flatten_property_value( $value ) {
+		if ( ! is_array( $value ) && ! is_object( $value ) ) {
+			return $value;
+		}
+
+		if ( array() === $value ) {
+			return '';
+		}

-			// Check if array is indexed (not associative) and contains only scalar values.
-			$is_indexed_array = array_keys( $value ) === range( 0, count( $value ) - 1 );
-			$has_scalar_only  = ! array_filter(
+		$is_scalar_list = is_array( $value )
+			&& array_keys( $value ) === range( 0, count( $value ) - 1 )
+			&& ! array_filter(
 				$value,
-				function ( $item ) {
+				static function ( $item ) {
 					return is_array( $item ) || is_object( $item );
 				}
 			);

-			if ( $is_indexed_array && $has_scalar_only ) {
-				// Indexed arrays with scalar values: join as comma string.
-				$properties[ $key ] = implode( ',', array_map( 'strval', $value ) );
-				continue;
-			}
-
-			// Associative arrays or nested arrays become JSON strings.
-			$encoded            = wp_json_encode( $value, JSON_HEX_TAG | JSON_UNESCAPED_SLASHES );
-			$properties[ $key ] = ( false === $encoded ) ? '' : $encoded;
+		if ( $is_scalar_list ) {
+			return implode( ',', $value );
 		}

-		return $properties;
+		$encoded = wp_json_encode( $value, JSON_UNESCAPED_SLASHES );
+
+		return false === $encoded ? '' : $encoded;
 	}

 	/**
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 b858d9ddfea..f101a91fd3f 100644
--- a/packages/php/woocommerce-analytics/src/class-wc-analytics-tracking.php
+++ b/packages/php/woocommerce-analytics/src/class-wc-analytics-tracking.php
@@ -534,7 +534,7 @@ class WC_Analytics_Tracking {
 		$all_properties = array_merge( $properties, $required_properties );

 		foreach ( $all_properties as $key => $value ) {
-			$all_properties[ $key ] = self::flatten_property_value( $value );
+			$all_properties[ $key ] = Pixel_Builder::flatten_property_value( $value );
 		}

 		return $all_properties;
@@ -691,46 +691,19 @@ class WC_Analytics_Tracking {
 			&& mb_strlen( $name ) <= self::MAX_CLIENT_NAME_LENGTH;
 	}

-	/**
-	 * Reduce one property value to the string that goes into the pixel URL.
-	 *
-	 * The payload budget uses this same conversion to measure array values accurately.
-	 *
-	 * @since 0.18.0
-	 *
-	 * @param mixed $value Property value.
-	 * @return mixed The scalar it serializes to; non-array values are returned as-is.
-	 */
-	private static function flatten_property_value( $value ) {
-		if ( ! is_array( $value ) ) {
-			return $value;
-		}
-
-		if ( empty( $value ) ) {
-			return '';
-		}
-
-		// Not URL-encoded here: http_build_query() in Pixel_Builder encodes the whole URL, so encoding twice stores `%2F` in Tracks.
-		if ( array_keys( $value ) === range( 0, count( $value ) - 1 ) ) {
-			return implode( ',', $value );
-		}
-
-		return wp_json_encode( $value, JSON_UNESCAPED_SLASHES );
-	}
-
 	/**
 	 * Bytes one value contributes to the pixel URL.
 	 *
 	 * @since 0.18.0
 	 *
 	 * @param mixed $value Already-capped client value.
-	 * @return int Byte count after `flatten_property_value()` and the encoding
+	 * @return int Byte count after `Pixel_Builder::flatten_property_value()` and the encoding
 	 *             `http_build_query()` applies on top of it.
 	 */
 	private static function measure_client_value( $value ) {
 		// Match http_build_query()'s RFC1738 encoding.
 		// phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.urlencode_urlencode -- Deliberate: mirrors http_build_query()'s RFC1738 encoding so the budget measures the bytes the finished URL carries.
-		return strlen( urlencode( (string) self::flatten_property_value( $value ) ) );
+		return strlen( urlencode( (string) Pixel_Builder::flatten_property_value( $value ) ) );
 	}

 	/**
diff --git a/packages/php/woocommerce-analytics/tests/php/Pixel_Builder_Test.php b/packages/php/woocommerce-analytics/tests/php/Pixel_Builder_Test.php
index ecb7c20457c..6c7c5fc9d7e 100644
--- a/packages/php/woocommerce-analytics/tests/php/Pixel_Builder_Test.php
+++ b/packages/php/woocommerce-analytics/tests/php/Pixel_Builder_Test.php
@@ -360,6 +360,23 @@ class Pixel_Builder_Test extends BaseTestCase {
 		$this->assertSame( '', $result['items'] );
 	}

+	/**
+	 * Test validate_and_sanitize encodes nested arrays and objects as JSON.
+	 */
+	public function test_validate_and_sanitize_handles_nested_arrays_and_objects(): void {
+		$properties = array(
+			'_en'    => 'woocommerceanalytics_test_event',
+			'nested' => array( 1, array( 2 ) ),
+			'obj'    => (object) array( 'a' => 1 ),
+		);
+
+		$result = Pixel_Builder::validate_and_sanitize( $properties );
+
+		$this->assertIsArray( $result );
+		$this->assertSame( '[1,[2]]', $result['nested'] );
+		$this->assertSame( '{"a":1}', $result['obj'] );
+	}
+
 	/**
 	 * Test validate_and_sanitize returns error for invalid property name.
 	 */
diff --git a/packages/php/woocommerce-analytics/tests/php/WC_Analytics_Tracking_Array_Props_Test.php b/packages/php/woocommerce-analytics/tests/php/WC_Analytics_Tracking_Array_Props_Test.php
index 1ec3fbbdc3e..404b19cceff 100644
--- a/packages/php/woocommerce-analytics/tests/php/WC_Analytics_Tracking_Array_Props_Test.php
+++ b/packages/php/woocommerce-analytics/tests/php/WC_Analytics_Tracking_Array_Props_Test.php
@@ -87,4 +87,53 @@ class WC_Analytics_Tracking_Array_Props_Test extends BaseTestCase {

 		$this->assertSame( array( 'a' => 1, 'b' => 'x/y' ), json_decode( $query['meta'], true ) );
 	}
+
+	/**
+	 * Values a server-side filter returns reach the pixel without a PHP warning or a bracketed key.
+	 *
+	 * @dataProvider provide_filtered_values
+	 *
+	 * @param mixed  $value    Value the event-props filter adds.
+	 * @param string $expected Value Tracks should read back.
+	 */
+	public function test_filtered_value_flattens_to_json( $value, string $expected ): void {
+		$add_prop = static function ( $props ) use ( $value ) {
+			$props['probe'] = $value;
+			return $props;
+		};
+		add_filter( 'jetpack_woocommerce_analytics_event_props', $add_prop );
+
+		$warnings = array();
+		set_error_handler(
+			static function ( $errno, $errstr ) use ( &$warnings ) {
+				$warnings[] = $errstr;
+				return true;
+			}
+		);
+
+		try {
+			$raw = $this->raw_pixel_query( array() );
+		} finally {
+			restore_error_handler();
+			remove_filter( 'jetpack_woocommerce_analytics_event_props', $add_prop );
+		}
+
+		$this->assertSame( array(), $warnings );
+		$this->assertDoesNotMatchRegularExpression( '/(^|&)[^=&]*(%5B|%5D|\[|\])[^=&]*=/i', $raw );
+
+		parse_str( $raw, $query );
+		$this->assertSame( $expected, $query['probe'] );
+	}
+
+	/**
+	 * Filter return values that neither a comma join nor http_build_query() can represent.
+	 *
+	 * @return array<string, array{mixed, string}>
+	 */
+	public function provide_filtered_values(): array {
+		return array(
+			'nested indexed array' => array( array( 1, array( 2 ) ), '[1,[2]]' ),
+			'object'               => array( (object) array( 'a' => 1 ), '{"a":1}' ),
+		);
+	}
 }