Commit a343b44f7ba for woocommerce

commit a343b44f7baacda23e5dae793a6ec8e683a155d6
Author: Bogdan Ungureanu <bogdanungureanu21@gmail.com>
Date:   Tue Oct 6 17:34:31 2026 +0300

    Track product customs field adoption in the tracker snapshot (#69168)

    Measures adoption of the customs fields. Adds a product_customs key to the woocommerce_tracker_data snapshot, with counts of published products and variations that have each field set

diff --git a/docs/features/products/product-customs-data.md b/docs/features/products/product-customs-data.md
new file mode 100644
index 00000000000..008cdf77901
--- /dev/null
+++ b/docs/features/products/product-customs-data.md
@@ -0,0 +1,64 @@
+---
+post_title: Product customs data
+sidebar_label: Customs data
+sidebar_position: 4
+---
+
+# Product customs data
+
+WooCommerce stores three optional customs fields on products and variations. They help merchants keep a description of goods for cross-border shipping. They do not calculate duties or produce shipping labels.
+
+## Set customs details in the product editor
+
+Customs fields appear in the **Shipping** tab, so any product type that shows that tab has them. Virtual products and variations have no customs fields in the editor.
+
+Open a product in the classic product editor. In **Product data → Shipping → Customs**, enter:
+
+-   **Commodity code:** The HS (Harmonized System) code: an HS6 code or a longer country-specific code, such as `0901.21.0010`. WooCommerce removes punctuation and spaces and stores `0901210010`. The code must contain 6–14 digits; letters are rejected. Keep leading zeros.
+-   **Country of origin:** The country where the product was made. The stored value is a two-letter ISO country code such as `BR`.
+-   **Customs description:** Plain text for customs forms, up to 35 characters. HTML tags are removed, and runs of spaces or line breaks become a single space. Letters in any language, numbers, spaces, punctuation and standard keyboard symbols such as `&`, `%` or `$` are allowed; emoji and other symbols, such as `™` or `€`, are rejected. A `<` followed directly by text, as in `<5kg`, is treated as the start of a tag and removed.
+
+All three fields can be left empty. For a variable product, expand a variation and set its customs values in the variation's shipping fields. A blank variation field inherits the parent product's value. Enter a value to override the parent; clear it to inherit again. Duplicating a product copies its values and its variations' overrides.
+
+## Import and export CSV files
+
+Product CSV files use the columns **Commodity code (HS code)**, **Country of origin**, and **Customs description**. The machine-name headers `customs_commodity_code`, `customs_country_of_origin`, and `customs_description` are also accepted on import. These columns work for product and variation rows.
+
+-   A blank cell in a mapped customs column clears the value. On a variation row, this means the variation inherits the parent's value.
+-   Omit a column to leave that value unchanged for every row.
+-   Invalid values fail the row with a field-specific error; other valid rows still import.
+-   Exports contain an empty cell when no value is stored. Variation rows contain only the variation's own overrides.
+
+Spreadsheet apps can drop leading zeros from codes such as `0901210010`. Format the commodity code column as text before editing and saving the file.
+
+## Use the REST API
+
+The `/wc/v3/products` and `/wc/v3/products/{product_id}/variations` endpoints expose `customs_commodity_code`, `customs_country_of_origin`, and `customs_description`. The same fields are available on the `/wc-analytics/products` endpoints.
+
+-   The default `view` context returns resolved values. Variations include values inherited from the parent product.
+-   `context=edit` returns stored values. A variation that inherits a value returns `null`.
+-   Omit a field from a POST or PUT request to preserve it. Send `null` or an empty string to clear it.
+-   Invalid values return HTTP 400 with a `woocommerce_product_invalid_customs_*` error code. Non-string values return `rest_invalid_param`, except inside batch requests, where the item error uses the `woocommerce_product_invalid_customs_*` code.
+-   In batch requests, an invalid item returns an error object inside a 200 response, and the other items are still applied.
+
+```json
+{
+	"customs_commodity_code": "0901.21.0010",
+	"customs_country_of_origin": "br",
+	"customs_description": "Roasted coffee"
+}
+```
+
+The saved values are `0901210010`, `BR`, and `Roasted coffee`.
+
+## Use the PHP methods and filters
+
+`WC_Product` provides `get_customs_commodity_code()`, `get_customs_country_of_origin()`, and `get_customs_description()`, with matching setters. In the default `view` context, a variation getter returns the parent's value when the variation has none. Pass `'edit'` to get the stored value.
+
+The getters run the `woocommerce_product_get_customs_*` filters for products and the `woocommerce_product_variation_get_customs_*` filters for variations. The variation filters also run for inherited values.
+
+To clear a value, call the setter with `null` or `''`. `set_props()` ignores `null`, so pass `''` when clearing through it. The values use the protected meta keys `_customs_commodity_code`, `_customs_country_of_origin`, and `_customs_description`; use the CRUD methods to read or change them.
+
+## Usage tracking
+
+When usage tracking is enabled, WooCommerce's periodic tracker counts published products with a commodity code, country of origin, or customs description, and published variations with their own commodity code, country of origin, or customs description. It sends only aggregate counts, without product IDs or field values.
diff --git a/plugins/woocommerce/changelog/feat-wooplug-5501-customs-5-telemetry b/plugins/woocommerce/changelog/feat-wooplug-5501-customs-5-telemetry
new file mode 100644
index 00000000000..8e6579a3c2a
--- /dev/null
+++ b/plugins/woocommerce/changelog/feat-wooplug-5501-customs-5-telemetry
@@ -0,0 +1,3 @@
+Significance: patch
+Type: dev
+Comment: Add product customs adoption counts to the usage tracker snapshot; no merchant-facing change.
diff --git a/plugins/woocommerce/includes/class-woocommerce.php b/plugins/woocommerce/includes/class-woocommerce.php
index 0a53ee0d110..2a9de530e94 100644
--- a/plugins/woocommerce/includes/class-woocommerce.php
+++ b/plugins/woocommerce/includes/class-woocommerce.php
@@ -425,6 +425,7 @@ final class WooCommerce {
 		$container->get( Automattic\WooCommerce\Internal\Utilities\LegacyRestApiStub::class )->register();
 		$container->get( LegacySelect2UsageTracker::class )->register();
 		$container->get( Automattic\WooCommerce\Internal\VariationGallery\Telemetry::class )->register();
+		$container->get( Automattic\WooCommerce\Internal\ProductCustoms\Telemetry::class )->register();
 		$container->get( Automattic\WooCommerce\Internal\Email\EmailStyleSync::class )->register();
 		$container->get( EmailLogger::class )->register();
 		$container->get( VisualAttributeTermAdmin::class )->register();
diff --git a/plugins/woocommerce/src/Internal/ProductCustoms/Telemetry.php b/plugins/woocommerce/src/Internal/ProductCustoms/Telemetry.php
new file mode 100644
index 00000000000..4f8ed5b9fac
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/ProductCustoms/Telemetry.php
@@ -0,0 +1,97 @@
+<?php
+/**
+ * Aggregate product customs data for the WooCommerce tracker.
+ */
+
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\Internal\ProductCustoms;
+
+use Automattic\WooCommerce\Enums\ProductStatus;
+use Automattic\WooCommerce\Internal\RegisterHooksInterface;
+
+defined( 'ABSPATH' ) || exit;
+
+/**
+ * Counts published products and variations with customs values in the periodic tracker snapshot.
+ */
+class Telemetry implements RegisterHooksInterface {
+
+	/**
+	 * Snapshot keys by "post type/meta key".
+	 */
+	private const COUNT_KEYS = array(
+		'product/_customs_commodity_code'              => 'products_with_commodity_code',
+		'product/_customs_country_of_origin'           => 'products_with_country_of_origin',
+		'product/_customs_description'                 => 'products_with_customs_description',
+		'product_variation/_customs_commodity_code'    => 'variations_with_commodity_code',
+		'product_variation/_customs_country_of_origin' => 'variations_with_country_of_origin',
+		'product_variation/_customs_description'       => 'variations_with_customs_description',
+	);
+
+	/**
+	 * Registers the periodic snapshot filter.
+	 *
+	 * @since 11.3.0
+	 *
+	 * @return void
+	 */
+	public function register() {
+		add_filter( 'woocommerce_tracker_data', array( $this, 'handle_woocommerce_tracker_data' ) );
+	}
+
+	/**
+	 * Handle the woocommerce_tracker_data filter by adding aggregate customs counts.
+	 *
+	 * @internal
+	 *
+	 * @param mixed $data The tracker payload, which third-party filters may have changed.
+	 * @return mixed
+	 */
+	public function handle_woocommerce_tracker_data( $data ) {
+		if ( ! is_array( $data ) ) {
+			return $data;
+		}
+
+		$data['product_customs'] = $this->collect_snapshot();
+		return $data;
+	}
+
+	/**
+	 * Counts published products and variations with stored customs values.
+	 *
+	 * @return array<string, int>
+	 */
+	private function collect_snapshot(): array {
+		global $wpdb;
+
+		$counts = array_fill_keys( self::COUNT_KEYS, 0 );
+		$rows   = $wpdb->get_results(
+			$wpdb->prepare(
+				"SELECT p.post_type, pm.meta_key, COUNT(DISTINCT pm.post_id) AS product_count
+				 FROM {$wpdb->postmeta} pm
+				 INNER JOIN {$wpdb->posts} p ON p.ID = pm.post_id
+				 WHERE pm.meta_key IN (%s, %s, %s)
+				   AND pm.meta_value <> ''
+				   AND p.post_type IN (%s, %s)
+				   AND p.post_status = %s
+				 GROUP BY p.post_type, pm.meta_key",
+				'_customs_commodity_code',
+				'_customs_country_of_origin',
+				'_customs_description',
+				'product',
+				'product_variation',
+				ProductStatus::PUBLISH
+			)
+		);
+
+		foreach ( (array) $rows as $row ) {
+			$key = self::COUNT_KEYS[ $row->post_type . '/' . $row->meta_key ] ?? null;
+			if ( null !== $key ) {
+				$counts[ $key ] = (int) $row->product_count;
+			}
+		}
+
+		return $counts;
+	}
+}
diff --git a/plugins/woocommerce/tests/php/src/Internal/ProductCustoms/TelemetryTest.php b/plugins/woocommerce/tests/php/src/Internal/ProductCustoms/TelemetryTest.php
new file mode 100644
index 00000000000..b2323f74f86
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/ProductCustoms/TelemetryTest.php
@@ -0,0 +1,107 @@
+<?php
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Tests\Internal\ProductCustoms;
+
+use Automattic\WooCommerce\Enums\ProductStatus;
+use Automattic\WooCommerce\Internal\ProductCustoms\Telemetry;
+
+/**
+ * Customs adoption snapshot tests.
+ */
+class TelemetryTest extends \WC_Unit_Test_Case {
+
+	/**
+	 * The System Under Test.
+	 *
+	 * @var Telemetry
+	 */
+	private $sut;
+
+	/**
+	 * Set up test fixtures.
+	 */
+	public function setUp(): void {
+		parent::setUp();
+		$this->sut = new Telemetry();
+	}
+
+	/**
+	 * @testdox Counts published products and variations once per populated field, keeping other tracker data.
+	 */
+	public function test_counts_published_products_and_variations(): void {
+		$before  = $this->get_snapshot();
+		$product = \WC_Helper_Product::create_simple_product();
+		add_post_meta( $product->get_id(), '_customs_commodity_code', '090121' );
+		add_post_meta( $product->get_id(), '_customs_commodity_code', '090121' );
+		add_post_meta( $product->get_id(), '_customs_country_of_origin', 'BR' );
+		add_post_meta( $product->get_id(), '_customs_description', 'Roasted coffee' );
+		$origin_only = \WC_Helper_Product::create_simple_product();
+		add_post_meta( $origin_only->get_id(), '_customs_country_of_origin', 'US' );
+		add_post_meta( $origin_only->get_id(), '_customs_commodity_code', '' );
+
+		foreach ( array( ProductStatus::DRAFT, ProductStatus::PRIVATE, ProductStatus::TRASH ) as $status ) {
+			$excluded = \WC_Helper_Product::create_simple_product();
+			$excluded->set_status( $status );
+			$excluded->save();
+			add_post_meta( $excluded->get_id(), '_customs_commodity_code', '090121' );
+			add_post_meta( $excluded->get_id(), '_customs_country_of_origin', 'BR' );
+			add_post_meta( $excluded->get_id(), '_customs_description', 'Coffee' );
+		}
+
+		$parent = new \WC_Product_Variable();
+		$parent->save();
+		$variation = new \WC_Product_Variation();
+		$variation->set_parent_id( $parent->get_id() );
+		$variation->save();
+		add_post_meta( $variation->get_id(), '_customs_commodity_code', '090121' );
+		add_post_meta( $variation->get_id(), '_customs_country_of_origin', 'BR' );
+		add_post_meta( $variation->get_id(), '_customs_description', 'Coffee' );
+		$private_variation = new \WC_Product_Variation();
+		$private_variation->set_parent_id( $parent->get_id() );
+		$private_variation->set_status( ProductStatus::PRIVATE );
+		$private_variation->save();
+		add_post_meta( $private_variation->get_id(), '_customs_commodity_code', '090121' );
+		add_post_meta( $private_variation->get_id(), '_customs_country_of_origin', 'BR' );
+
+		$data     = $this->sut->handle_woocommerce_tracker_data( array( 'existing' => 'value' ) );
+		$snapshot = $data['product_customs'];
+
+		$this->assertSame( 'value', $data['existing'], 'Existing tracker data must be preserved.' );
+		$this->assertSame(
+			array(
+				'products_with_commodity_code'        => $before['products_with_commodity_code'] + 1,
+				'products_with_country_of_origin'     => $before['products_with_country_of_origin'] + 2,
+				'products_with_customs_description'   => $before['products_with_customs_description'] + 1,
+				'variations_with_commodity_code'      => $before['variations_with_commodity_code'] + 1,
+				'variations_with_country_of_origin'   => $before['variations_with_country_of_origin'] + 1,
+				'variations_with_customs_description' => $before['variations_with_customs_description'] + 1,
+			),
+			$snapshot,
+			'Only published products and variations with non-empty values should be counted, once per field.'
+		);
+	}
+
+	/**
+	 * @testdox Leaves malformed upstream tracker data untouched without querying products.
+	 * @testWith [null]
+	 *           ["invalid"]
+	 * @param mixed $data Upstream filter result.
+	 */
+	public function test_invalid_filter_input( $data ): void {
+		global $wpdb;
+		$queries = $wpdb->num_queries;
+
+		$this->assertSame( $data, $this->sut->handle_woocommerce_tracker_data( $data ), 'Non-array tracker data must be returned unchanged.' );
+		$this->assertSame( $queries, $wpdb->num_queries, 'No query should run for non-array tracker data.' );
+	}
+
+	/**
+	 * Returns the current customs snapshot.
+	 *
+	 * @return array<string, int>
+	 */
+	private function get_snapshot(): array {
+		return $this->sut->handle_woocommerce_tracker_data( array() )['product_customs'];
+	}
+}