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