Commit e11ae8b63f for woocommerce

commit e11ae8b63f1d0aa2ee51fc6c40d61d842e013ffb
Author: Jasper Kang <kangzjnet@gmail.com>
Date:   Fri Dec 19 14:37:08 2025 +1300

    Add batched pixel requests to WC_Tracks_Client for improved performance (#62461)

    * Add batched pixel requests to WC_Tracks_Client for improved performance

    This commit implements request batching for tracking pixels to improve performance by sending multiple tracking events together rather than individually.

    Changes:
    * Queue tracking pixels during request execution instead of sending immediately
    * Send all queued pixels together on the shutdown hook using WordPress Requests library
    * Use Requests::request_multiple() with blocking => false for parallel non-blocking execution
    * Fallback to sequential wp_safe_remote_get() when batch API is unavailable
    * Add wc_tracks_use_batch_requests filter to allow developers to disable batching
    * Add comprehensive PHPUnit tests for batching functionality

    Benefits:
    * Performance improvement: Parallel execution reduces total request time
    * Non-blocking: Uses blocking => false to ensure zero impact on page load time
    * Efficient batching: Reduces overhead by grouping related tracking requests
    * WordPress-native: Uses WordPress Requests library instead of direct curl usage
    * Graceful degradation: Automatically falls back when batching is unavailable
    * Backward compatible: Maintains full compatibility with existing WC_Tracks_Client usage

    * Intercept HTTP requests in WC_Tracks_Client tests

    Add HTTP request interception to prevent actual network calls during testing.

    Changes:
    * Add pre_http_request filter to intercept pixel requests
    * Track intercepted requests in $intercepted_requests property
    * Update tests to verify intercepted requests instead of actual sends
    * Add test_http_requests_are_intercepted to verify interception works
    * Add test_fallback_requests_are_intercepted for non-batched requests
    * Verify non-blocking parameter is set correctly in intercepted requests

    This ensures tests run without making actual HTTP calls to pixel.wp.com

    * Address CodeRabbit AI feedback on tests

    - Add else clause to test_can_use_batch_requests_when_supported to verify false when library unavailable
    - Rename test_send_with_requests_multiple_handles_exceptions_gracefully to test_send_with_requests_multiple_handles_empty_array to match actual behavior
    - Use addToAssertionCount() instead of assertTrue(true) for explicit assertion tracking

    * Add changelog entry for batched pixel requests

    * Refactor batched pixel tracking to preserve backward compatibility

    Changed the architecture to add new `record_pixel_batched()` and `record_event_batched()` methods instead of modifying the existing `record_pixel()` method. This ensures that any code relying on immediate pixel sending continues to work unchanged.

    Key changes:
    - Restored `record_pixel()` to its original immediate sending behavior
    - Added `record_pixel_batched()` for batched pixel requests
    - Added `record_event_batched()` for batched event recording
    - Updated all batching tests to use the new methods
    - Added new test for immediate sending behavior

    * Remove strict_types declaration from WC_Tracks_Client

    The declare(strict_types=1) declaration was removed to maintain consistency with the existing codebase style in the includes/ directory, which doesn't use strict type declarations.

    * Update PHPStan baseline for batched pixel methods

    Updated baseline to account for the new record_event_batched() method which duplicates some patterns from record_event():
    - Updated count for is_wp_error check from 1 to 2
    - Updated count for build_pixel_url parameter check from 1 to 2
    - Added ignores for method_exists checks in can_use_batch_requests()
    - Added ignores for missing return types in new private methods

    * Add blank line after docblock in WC_Tracks_Client

    Minor formatting improvement to add spacing between docblock and use statements.

    * Fix PHPStan baseline pattern escaping for WpOrg namespace

    Corrected the regex pattern to use proper backslash escaping (2 backslashes instead of 4) to match the actual error message format.

    * Apply suggestion from @kangzj

    * Revert PHPStan baseline escaping to use 4 backslashes

    The regex pattern needs 4 backslashes to properly match 2 backslashes in the error message. Each backslash in the error requires 2 backslashes in the regex pattern to escape it.

    * Fix changelog type from 'add' to 'performance'

    Changed Type field to 'performance' to match the actual nature of this change - it's a performance improvement rather than a new feature.

    * Add filter for intercepting batched requests in tests

    - Added wc_tracks_batch_requests_before_send filter to allow tests to intercept batched requests
    - Added intercept_batch_requests method in tests to capture batched requests without making actual HTTP calls
    - Re-added strict_types declaration as required by linter
    - Fixed test failures where Requests::request_multiple() bypassed pre_http_request filter

    * Remove unmatched method_exists patterns from PHPStan baseline

    These baseline entries were not matching the actual reported errors, likely due to PHPStan's ellipsis formatting of long class names in error messages.

    * Add return type hints to fix PHPStan errors

    Added void return type hints to:
    - queue_pixel_for_batch()
    - send_batched_pixels()
    - send_with_requests_multiple()

    Removed these entries from PHPStan baseline since they're now properly fixed.

    * Add changefile(s) from automation for the following project(s): woocommerce

    * Delete plugins/woocommerce/changelog/62461-add-batched-pixel-requests-to-wc-tracks-client

    * Remove filter added solely for testing purposes

    Removed wc_tracks_batch_requests_before_send filter that was only added to intercept requests in tests. Refactored tests to focus on verifying queue behavior rather than intercepting HTTP calls:
    - Removed intercept_batch_requests() method from tests
    - Updated tests to verify queue management instead of HTTP interception
    - Fixed tests to use record_event_batched() instead of record_event()
    - Tests now properly validate batching behavior without relying on production filters

    * Add parameter type hints per CodeRabbit feedback

    Added string and array type hints to private method parameters:
    - queue_pixel_for_batch(): Added string type hint for $pixel parameter
    - send_with_requests_multiple(): Added array type hint for $pixels parameter

    This improves type safety and aligns with the strict_types declaration.

    * Add PHPStan baseline entries for method_exists checks

    Suppressed PHPStan warnings for method_exists() checks on Requests::request_multiple(). These checks are intentionally defensive programming to verify both class existence and method availability across different WordPress/Requests library versions.

    * Fix PHPStan baseline pattern to match ellipsis in error message

    Changed the baseline pattern to use '.+' wildcard to match PHPStan's ellipsis formatting of long class names (e.g., 'WpOrg\\Requests…' instead of the full 'WpOrg\\Requests\\Requests').

    * Fix build_pixel_url() calls to use correct signature

    Removed unnecessary parameter from build_pixel_url() calls - the method requires 0 parameters but was being invoked with 1.
    - Fixed record_event() to call build_pixel_url() without parameter
    - Fixed record_event_batched() to call build_pixel_url() without parameter
    - Removed PHPStan baseline entry since issue is now resolved

    * Apply suggestion from @kangzj

    * Fix error handling in record_event_batched()

    Corrected the error check to properly access the error property on WC_Tracks_Event object:
    - Changed from checking is_wp_error($event) which always returns false
    - Now checks isset($event->error) && is_wp_error($event->error)
    - Returns $event->error instead of $event when an error occurs
    - Updated PHPStan baseline count from 2 to 1 since one instance is now fixed

    * Fix PHPDoc type for WC_Tracks_Event::error property

    The error property is only set when validation fails, so it should be
    typed as WP_Error|null to accurately reflect its nullable nature.

    * Update PHPStan baseline and fix method call

    * Move PHPStan suppressions from baseline to inline comments

    Replace PHPStan baseline entries for method_exists checks with inline
    @phpstan-ignore comments in can_use_batch_requests() method.

    * Update PHPStan baseline with count and path fields

    * Add periods to PHPStan ignore comments

    * Move PHPStan ignore comments to end of lines

    * add testdox block

    ---------

    Co-authored-by: github-actions <github-actions@github.com>

diff --git a/plugins/woocommerce/changelog/62461-add-tracks-client-batch-requests b/plugins/woocommerce/changelog/62461-add-tracks-client-batch-requests
new file mode 100644
index 0000000000..c45191bd29
--- /dev/null
+++ b/plugins/woocommerce/changelog/62461-add-tracks-client-batch-requests
@@ -0,0 +1,4 @@
+Significance: minor
+Type: performance
+
+Implement batched pixel requests for improved performance when sending tracking events
\ No newline at end of file
diff --git a/plugins/woocommerce/includes/tracks/class-wc-tracks-client.php b/plugins/woocommerce/includes/tracks/class-wc-tracks-client.php
index 7426fe4c37..52a21ab65f 100644
--- a/plugins/woocommerce/includes/tracks/class-wc-tracks-client.php
+++ b/plugins/woocommerce/includes/tracks/class-wc-tracks-client.php
@@ -30,6 +30,20 @@ class WC_Tracks_Client {
 	 */
 	const USER_AGENT_SLUG = 'tracks-client';

+	/**
+	 * Batch pixel queue for batched requests.
+	 *
+	 * @var array
+	 */
+	private static $pixel_batch_queue = array();
+
+	/**
+	 * Whether the shutdown hook has been registered.
+	 *
+	 * @var bool
+	 */
+	private static $shutdown_hook_registered = false;
+
 	/**
 	 * Initialize tracks client class
 	 *
@@ -101,6 +115,33 @@ class WC_Tracks_Client {
 		return self::record_pixel( $pixel );
 	}

+	/**
+	 * Record a Tracks event using batched requests for improved performance.
+	 * Events are queued and sent together on the shutdown hook.
+	 *
+	 * @since 10.5.0
+	 *
+	 * @param  array $event Array of event properties.
+	 * @return bool|WP_Error         True on success, WP_Error on failure.
+	 */
+	public static function record_event_batched( $event ) {
+		if ( ! $event instanceof WC_Tracks_Event ) {
+			$event = new WC_Tracks_Event( $event );
+		}
+
+		if ( isset( $event->error ) && is_wp_error( $event->error ) ) {
+			return $event->error;
+		}
+
+		$pixel = $event->build_pixel_url();
+
+		if ( ! $pixel ) {
+			return new WP_Error( 'invalid_pixel', 'cannot generate tracks pixel for given input', 400 );
+		}
+
+		return self::record_pixel_batched( $pixel );
+	}
+
 	/**
 	 * Synchronously request the pixel.
 	 *
@@ -124,6 +165,38 @@ class WC_Tracks_Client {
 		return true;
 	}

+	/**
+	 * Record a pixel using batched requests for improved performance.
+	 * Pixels are queued and sent together on the shutdown hook.
+	 *
+	 * @since 10.5.0
+	 *
+	 * @param string $pixel pixel url and query string.
+	 * @return bool Always returns true.
+	 */
+	public static function record_pixel_batched( $pixel ) {
+		// Check if batching is enabled and supported.
+		$use_batching = self::can_use_batch_requests();
+
+		/**
+		 * Filters whether to use batch requests for tracking pixels.
+		 *
+		 * @since 10.5.0
+		 *
+		 * @param bool $use_batching Whether to use batch requests. Default true if supported.
+		 */
+		$use_batching = apply_filters( 'wc_tracks_use_batch_requests', $use_batching );
+
+		if ( $use_batching ) {
+			// Queue the pixel and send on shutdown.
+			self::queue_pixel_for_batch( $pixel );
+			return true;
+		}
+
+		// Fallback to immediate sending if batching is not supported or disabled.
+		return self::record_pixel( $pixel );
+	}
+
 	/**
 	 * Create a timestamp representing milliseconds since 1970-01-01
 	 *
@@ -222,6 +295,101 @@ class WC_Tracks_Client {

 		return $anon_id;
 	}
+
+	/**
+	 * Check if batch requests are supported.
+	 *
+	 * @since 10.5.0
+	 *
+	 * @return bool Whether batch requests are supported.
+	 */
+	private static function can_use_batch_requests() {
+		// Check if the Requests library supports request_multiple().
+		return ( class_exists( 'WpOrg\Requests\Requests' ) && method_exists( 'WpOrg\Requests\Requests', 'request_multiple' ) ) // @phpstan-ignore function.alreadyNarrowedType
+			|| ( class_exists( 'Requests' ) && method_exists( 'Requests', 'request_multiple' ) ); // @phpstan-ignore function.alreadyNarrowedType
+	}
+
+	/**
+	 * Queue a pixel URL for batch sending.
+	 *
+	 * @since 10.5.0
+	 *
+	 * @param string $pixel The pixel URL to queue.
+	 */
+	private static function queue_pixel_for_batch( string $pixel ): void {
+		self::$pixel_batch_queue[] = $pixel;
+
+		// Register shutdown hook once.
+		if ( ! self::$shutdown_hook_registered ) {
+			add_action( 'shutdown', array( __CLASS__, 'send_batched_pixels' ), 20 );
+			self::$shutdown_hook_registered = true;
+		}
+	}
+
+	/**
+	 * Send all queued pixels using batched non-blocking requests.
+	 * This runs on the shutdown hook to batch all requests together.
+	 *
+	 * Uses Requests library's request_multiple() for true parallel batching via curl_multi.
+	 *
+	 * @since 10.5.0
+	 */
+	public static function send_batched_pixels(): void {
+		if ( empty( self::$pixel_batch_queue ) ) {
+			return;
+		}
+
+		// Add request timestamp and nocache to all pixels.
+		$pixels_to_send = array();
+		foreach ( self::$pixel_batch_queue as $pixel ) {
+			$pixels_to_send[] = self::add_request_timestamp_and_nocache( $pixel );
+		}
+
+		// Send with Requests library for true parallel batching.
+		self::send_with_requests_multiple( $pixels_to_send );
+
+		// Clear the queue.
+		self::$pixel_batch_queue = array();
+	}
+
+	/**
+	 * Send pixels using Requests::request_multiple() for parallel non-blocking execution.
+	 * Uses blocking => false for true non-blocking behavior via curl_multi.
+	 *
+	 * @since 10.5.0
+	 *
+	 * @param array $pixels Array of pixel URLs to send.
+	 */
+	private static function send_with_requests_multiple( array $pixels ): void {
+		$requests = array();
+		$options  = array(
+			'blocking' => false, // Non-blocking mode - returns immediately.
+			'timeout'  => 1,
+		);
+
+		foreach ( $pixels as $pixel ) {
+			$requests[] = array(
+				'url'     => $pixel,
+				'headers' => array(),
+				'data'    => array(),
+				'type'    => 'GET',
+			);
+		}
+
+		try {
+			// Try modern namespaced version first.
+			if ( class_exists( 'WpOrg\Requests\Requests' ) ) {
+				\WpOrg\Requests\Requests::request_multiple( $requests, $options );
+			} elseif ( class_exists( 'Requests' ) ) {
+				\Requests::request_multiple( $requests, $options ); // phpcs:ignore PHPCompatibility.FunctionUse.RemovedFunctions.requestsDeprecated
+			}
+		} catch ( \Exception $e ) {
+			// Log error but don't break the site - tracking pixels should fail gracefully.
+			if ( function_exists( 'wc_get_logger' ) ) {
+				wc_get_logger()->error( 'WC_Tracks_Client: Batch pixel request failed - ' . $e->getMessage(), array( 'source' => 'wc-tracks' ) );
+			}
+		}
+	}
 }

 WC_Tracks_Client::init();
diff --git a/plugins/woocommerce/includes/tracks/class-wc-tracks-event.php b/plugins/woocommerce/includes/tracks/class-wc-tracks-event.php
index 3e3d9a656f..cd84fe3386 100644
--- a/plugins/woocommerce/includes/tracks/class-wc-tracks-event.php
+++ b/plugins/woocommerce/includes/tracks/class-wc-tracks-event.php
@@ -28,7 +28,7 @@ class WC_Tracks_Event {
 	/**
 	 * Error message as WP_Error.
 	 *
-	 * @var WP_Error
+	 * @var WP_Error|null
 	 */
 	public $error;

diff --git a/plugins/woocommerce/tests/php/includes/tracks/class-wc-tracks-client-test.php b/plugins/woocommerce/tests/php/includes/tracks/class-wc-tracks-client-test.php
new file mode 100644
index 0000000000..ad0691162b
--- /dev/null
+++ b/plugins/woocommerce/tests/php/includes/tracks/class-wc-tracks-client-test.php
@@ -0,0 +1,521 @@
+<?php
+declare(strict_types=1);
+
+/**
+ * Class WC_Tracks_Client_Test.
+ */
+class WC_Tracks_Client_Test extends \WC_Unit_Test_Case {
+
+	/**
+	 * Array to track intercepted HTTP requests.
+	 *
+	 * @var array
+	 */
+	private $intercepted_requests = array();
+
+	/**
+	 * Set up test.
+	 *
+	 * @return void
+	 */
+	public function setUp(): void {
+		parent::setUp();
+		include_once WC_ABSPATH . 'includes/tracks/class-wc-tracks.php';
+		include_once WC_ABSPATH . 'includes/tracks/class-wc-tracks-client.php';
+		include_once WC_ABSPATH . 'includes/tracks/class-wc-tracks-event.php';
+
+		// Clear any existing batch queue and hooks.
+		$this->reset_batch_state();
+
+		// Clear intercepted requests.
+		$this->intercepted_requests = array();
+
+		// Intercept HTTP requests to prevent actual network calls.
+		add_filter( 'pre_http_request', array( $this, 'intercept_http_requests' ), 10, 3 );
+	}
+
+	/**
+	 * Tear down test.
+	 *
+	 * @return void
+	 */
+	public function tearDown(): void {
+		$this->reset_batch_state();
+		remove_filter( 'pre_http_request', array( $this, 'intercept_http_requests' ), 10 );
+		parent::tearDown();
+	}
+
+	/**
+	 * Intercept HTTP requests to prevent actual network calls during testing.
+	 *
+	 * @param false|array|WP_Error $response    A preemptive return value of an HTTP request.
+	 * @param array                $parsed_args HTTP request arguments.
+	 * @param string               $url         The request URL.
+	 * @return array Mocked HTTP response.
+	 */
+	public function intercept_http_requests( $response, $parsed_args, $url ) {
+		// Track the intercepted request.
+		if ( strpos( $url, 'pixel.wp.com/t.gif' ) !== false ) {
+			$this->intercepted_requests[] = array(
+				'url'  => $url,
+				'args' => $parsed_args,
+			);
+
+			// Return a successful mock response.
+			return array(
+				'response' => array(
+					'code'    => 200,
+					'message' => 'OK',
+				),
+				'body'     => '',
+			);
+		}
+
+		return $response;
+	}
+
+	/**
+	 * Reset the batch state using reflection.
+	 *
+	 * @return void
+	 */
+	private function reset_batch_state() {
+		$reflection = new ReflectionClass( 'WC_Tracks_Client' );
+
+		$queue_property = $reflection->getProperty( 'pixel_batch_queue' );
+		$queue_property->setAccessible( true );
+		$queue_property->setValue( array() );
+
+		$hook_property = $reflection->getProperty( 'shutdown_hook_registered' );
+		$hook_property->setAccessible( true );
+		$hook_property->setValue( false );
+
+		// Remove shutdown hook if it exists.
+		remove_action( 'shutdown', array( 'WC_Tracks_Client', 'send_batched_pixels' ), 20 );
+	}
+
+	/**
+	 * Get the pixel batch queue using reflection.
+	 *
+	 * @return array
+	 */
+	private function get_batch_queue() {
+		$reflection = new ReflectionClass( 'WC_Tracks_Client' );
+		$property   = $reflection->getProperty( 'pixel_batch_queue' );
+		$property->setAccessible( true );
+		return $property->getValue();
+	}
+
+	/**
+	 * @testdox Should detect batch request support when Requests library is available.
+	 */
+	public function test_can_use_batch_requests_when_supported() {
+		$reflection = new ReflectionClass( 'WC_Tracks_Client' );
+		$method     = $reflection->getMethod( 'can_use_batch_requests' );
+		$method->setAccessible( true );
+
+		$can_batch = $method->invoke( null );
+
+		// Should return true if either WpOrg\Requests\Requests or Requests class exists with request_multiple method.
+		if ( class_exists( 'WpOrg\Requests\Requests' ) || class_exists( 'Requests' ) ) {
+			$this->assertTrue( $can_batch );
+		} else {
+			$this->assertFalse( $can_batch );
+		}
+	}
+
+	/**
+	 * @testdox Should queue pixels when batching is enabled.
+	 */
+	public function test_record_pixel_batched_queues_pixel_when_batching_enabled() {
+		// Ensure batching is supported.
+		if ( ! class_exists( 'WpOrg\Requests\Requests' ) && ! class_exists( 'Requests' ) ) {
+			$this->markTestSkipped( 'Requests library not available for batching.' );
+		}
+
+		$pixel = 'https://pixel.wp.com/t.gif?_en=test_event&test=value';
+
+		// Record the pixel using batched method.
+		WC_Tracks_Client::record_pixel_batched( $pixel );
+
+		// Get the batch queue.
+		$queue = $this->get_batch_queue();
+
+		// Pixel should be in the queue (without timestamp/nocache since that's added on send).
+		$this->assertCount( 1, $queue );
+		$this->assertEquals( $pixel, $queue[0] );
+	}
+
+	/**
+	 * @testdox Should send pixels immediately when using record_pixel without batching.
+	 */
+	public function test_record_pixel_sends_immediately() {
+		$pixel = 'https://pixel.wp.com/t.gif?_en=test_event&test=value';
+
+		// Clear intercepted requests.
+		$this->intercepted_requests = array();
+
+		// Record the pixel using immediate method.
+		WC_Tracks_Client::record_pixel( $pixel );
+
+		// Should be sent immediately, not queued.
+		$queue = $this->get_batch_queue();
+		$this->assertEmpty( $queue, 'Pixel should not be queued when using record_pixel().' );
+
+		// Should have been sent immediately via HTTP.
+		$this->assertCount( 1, $this->intercepted_requests, 'Pixel should be sent immediately.' );
+		$this->assertStringContainsString( '_en=test_event', $this->intercepted_requests[0]['url'] );
+	}
+
+	/**
+	 * @testdox Should queue all pixels when recording multiple batched pixels.
+	 */
+	public function test_record_multiple_pixels_batched_queues_all() {
+		// Ensure batching is supported.
+		if ( ! class_exists( 'WpOrg\Requests\Requests' ) && ! class_exists( 'Requests' ) ) {
+			$this->markTestSkipped( 'Requests library not available for batching.' );
+		}
+
+		$pixel1 = 'https://pixel.wp.com/t.gif?_en=event1';
+		$pixel2 = 'https://pixel.wp.com/t.gif?_en=event2';
+		$pixel3 = 'https://pixel.wp.com/t.gif?_en=event3';
+
+		WC_Tracks_Client::record_pixel_batched( $pixel1 );
+		WC_Tracks_Client::record_pixel_batched( $pixel2 );
+		WC_Tracks_Client::record_pixel_batched( $pixel3 );
+
+		$queue = $this->get_batch_queue();
+
+		$this->assertCount( 3, $queue );
+		$this->assertEquals( $pixel1, $queue[0] );
+		$this->assertEquals( $pixel2, $queue[1] );
+		$this->assertEquals( $pixel3, $queue[2] );
+	}
+
+	/**
+	 * @testdox Should register shutdown hook when pixels are batched.
+	 */
+	public function test_shutdown_hook_registered_when_pixel_batched() {
+		// Ensure batching is supported.
+		if ( ! class_exists( 'WpOrg\Requests\Requests' ) && ! class_exists( 'Requests' ) ) {
+			$this->markTestSkipped( 'Requests library not available for batching.' );
+		}
+
+		$pixel = 'https://pixel.wp.com/t.gif?_en=test_event';
+
+		// Record the pixel using batched method.
+		WC_Tracks_Client::record_pixel_batched( $pixel );
+
+		// Check if the shutdown hook is registered.
+		$this->assertNotFalse(
+			has_action( 'shutdown', array( 'WC_Tracks_Client', 'send_batched_pixels' ) ),
+			'Shutdown hook should be registered after queuing a pixel.'
+		);
+	}
+
+	/**
+	 * @testdox Should register shutdown hook only once when multiple pixels are batched.
+	 */
+	public function test_shutdown_hook_registered_only_once() {
+		// Ensure batching is supported.
+		if ( ! class_exists( 'WpOrg\Requests\Requests' ) && ! class_exists( 'Requests' ) ) {
+			$this->markTestSkipped( 'Requests library not available for batching.' );
+		}
+
+		WC_Tracks_Client::record_pixel_batched( 'https://pixel.wp.com/t.gif?_en=event1' );
+		WC_Tracks_Client::record_pixel_batched( 'https://pixel.wp.com/t.gif?_en=event2' );
+
+		// Get the priority value (should be 20).
+		$priority = has_action( 'shutdown', array( 'WC_Tracks_Client', 'send_batched_pixels' ) );
+
+		$this->assertEquals( 20, $priority, 'Shutdown hook should be registered with priority 20.' );
+
+		// Count how many times the hook is registered.
+		global $wp_filter;
+		$hook_count = 0;
+		if ( isset( $wp_filter['shutdown'] ) && isset( $wp_filter['shutdown'][20] ) ) {
+			foreach ( $wp_filter['shutdown'][20] as $hook ) {
+				if ( is_array( $hook['function'] ) && 'WC_Tracks_Client' === $hook['function'][0] && 'send_batched_pixels' === $hook['function'][1] ) {
+					++$hook_count;
+				}
+			}
+		}
+
+		$this->assertEquals( 1, $hook_count, 'Shutdown hook should only be registered once.' );
+	}
+
+	/**
+	 * @testdox Should clear queue after sending batched pixels.
+	 */
+	public function test_send_batched_pixels_clears_queue() {
+		// Ensure batching is supported.
+		if ( ! class_exists( 'WpOrg\Requests\Requests' ) && ! class_exists( 'Requests' ) ) {
+			$this->markTestSkipped( 'Requests library not available for batching.' );
+		}
+
+		$pixel = 'https://pixel.wp.com/t.gif?_en=test_event';
+
+		// Queue a pixel.
+		WC_Tracks_Client::record_pixel_batched( $pixel );
+
+		// Verify pixel was queued.
+		$queue = $this->get_batch_queue();
+		$this->assertCount( 1, $queue );
+
+		// Send the batched pixels.
+		WC_Tracks_Client::send_batched_pixels();
+
+		// Queue should be empty after sending.
+		$queue = $this->get_batch_queue();
+		$this->assertEmpty( $queue, 'Queue should be empty after sending batched pixels.' );
+	}
+
+	/**
+	 * @testdox Should do nothing when sending batched pixels with empty queue.
+	 */
+	public function test_send_batched_pixels_does_nothing_when_queue_empty() {
+		$this->reset_batch_state();
+
+		// Call send_batched_pixels with empty queue.
+		WC_Tracks_Client::send_batched_pixels();
+
+		// Should not cause any errors and queue should remain empty.
+		$queue = $this->get_batch_queue();
+		$this->assertEmpty( $queue );
+	}
+
+	/**
+	 * @testdox Should disable batching when wc_tracks_use_batch_requests filter returns false.
+	 */
+	public function test_filter_can_disable_batching() {
+		// Ensure batching is supported.
+		if ( ! class_exists( 'WpOrg\Requests\Requests' ) && ! class_exists( 'Requests' ) ) {
+			$this->markTestSkipped( 'Requests library not available for batching.' );
+		}
+
+		// Add filter to disable batching.
+		add_filter( 'wc_tracks_use_batch_requests', '__return_false' );
+
+		$pixel = 'https://pixel.wp.com/t.gif?_en=test_event';
+
+		// Record the pixel using batched method (but filter will disable batching).
+		WC_Tracks_Client::record_pixel_batched( $pixel );
+
+		// Queue should be empty since batching is disabled.
+		$queue = $this->get_batch_queue();
+		$this->assertEmpty( $queue, 'Queue should be empty when batching is disabled via filter.' );
+
+		// Clean up filter.
+		remove_filter( 'wc_tracks_use_batch_requests', '__return_false' );
+	}
+
+	/**
+	 * @testdox Should enable batching when wc_tracks_use_batch_requests filter returns true.
+	 */
+	public function test_filter_can_enable_batching() {
+		// Ensure batching is supported.
+		if ( ! class_exists( 'WpOrg\Requests\Requests' ) && ! class_exists( 'Requests' ) ) {
+			$this->markTestSkipped( 'Requests library not available for batching.' );
+		}
+
+		// Add filter to explicitly enable batching.
+		add_filter( 'wc_tracks_use_batch_requests', '__return_true' );
+
+		$pixel = 'https://pixel.wp.com/t.gif?_en=test_event';
+
+		// Record the pixel using batched method.
+		WC_Tracks_Client::record_pixel_batched( $pixel );
+
+		// Queue should have the pixel.
+		$queue = $this->get_batch_queue();
+		$this->assertCount( 1, $queue );
+
+		// Clean up filter.
+		remove_filter( 'wc_tracks_use_batch_requests', '__return_true' );
+	}
+
+	/**
+	 * @testdox Should generate timestamp in milliseconds.
+	 */
+	public function test_build_timestamp() {
+		$timestamp = WC_Tracks_Client::build_timestamp();
+
+		// Should be a numeric string.
+		$this->assertIsString( $timestamp );
+		$this->assertMatchesRegularExpression( '/^\d+$/', $timestamp );
+
+		// Should be approximately current time in milliseconds (13 digits).
+		$this->assertGreaterThanOrEqual( 13, strlen( $timestamp ) );
+	}
+
+	/**
+	 * @testdox Should add request timestamp and nocache parameters to pixel URL.
+	 */
+	public function test_add_request_timestamp_and_nocache() {
+		$pixel = 'https://pixel.wp.com/t.gif?_en=test_event';
+
+		$pixel_with_params = WC_Tracks_Client::add_request_timestamp_and_nocache( $pixel );
+
+		// Should contain _rt parameter.
+		$this->assertStringContainsString( '&_rt=', $pixel_with_params );
+
+		// Should contain _=_ terminator.
+		$this->assertStringContainsString( '&_=_', $pixel_with_params );
+
+		// Parse URL to verify parameters.
+		$parsed_url = wp_parse_url( $pixel_with_params );
+		parse_str( $parsed_url['query'], $query_params );
+
+		$this->assertArrayHasKey( '_en', $query_params );
+		$this->assertEquals( 'test_event', $query_params['_en'] );
+		$this->assertArrayHasKey( '_rt', $query_params );
+		$this->assertArrayHasKey( '_', $query_params );
+		$this->assertEquals( '_', $query_params['_'] );
+	}
+
+	/**
+	 * @testdox Should queue event when using record_event_batched.
+	 */
+	public function test_record_event_batched_queues_event() {
+		// Ensure batching is supported.
+		if ( ! class_exists( 'WpOrg\Requests\Requests' ) && ! class_exists( 'Requests' ) ) {
+			$this->markTestSkipped( 'Requests library not available for batching.' );
+		}
+
+		$event_props = array(
+			'_en'       => 'test_event',
+			'_ts'       => WC_Tracks_Client::build_timestamp(),
+			'test_prop' => 'test_value',
+		);
+
+		$event = new \WC_Tracks_Event( $event_props );
+
+		// Record the event using batched method.
+		WC_Tracks_Client::record_event_batched( $event );
+
+		// Queue should have one pixel.
+		$queue = $this->get_batch_queue();
+		$this->assertCount( 1, $queue );
+
+		// The queued pixel should contain the event name.
+		$this->assertStringContainsString( '_en=test_event', $queue[0] );
+		$this->assertStringContainsString( 'test_prop=test_value', $queue[0] );
+	}
+
+	/**
+	 * @testdox Should queue multiple events when batching.
+	 */
+	public function test_batch_multiple_events() {
+		// Ensure batching is supported.
+		if ( ! class_exists( 'WpOrg\Requests\Requests' ) && ! class_exists( 'Requests' ) ) {
+			$this->markTestSkipped( 'Requests library not available for batching.' );
+		}
+
+		// Create multiple events.
+		for ( $i = 1; $i <= 5; $i++ ) {
+			$event_props = array(
+				'_en'    => "test_event_$i",
+				'_ts'    => WC_Tracks_Client::build_timestamp(),
+				'number' => $i,
+			);
+
+			$event = new \WC_Tracks_Event( $event_props );
+			WC_Tracks_Client::record_event_batched( $event );
+		}
+
+		// Queue should have 5 pixels.
+		$queue = $this->get_batch_queue();
+		$this->assertCount( 5, $queue );
+
+		// Verify each event is in the queue.
+		for ( $i = 1; $i <= 5; $i++ ) {
+			$this->assertStringContainsString( "_en=test_event_$i", $queue[ $i - 1 ] );
+		}
+	}
+
+	/**
+	 * @testdox Should handle empty array when calling send_with_requests_multiple.
+	 */
+	public function test_send_with_requests_multiple_handles_empty_array() {
+		$reflection = new ReflectionClass( 'WC_Tracks_Client' );
+		$method     = $reflection->getMethod( 'send_with_requests_multiple' );
+		$method->setAccessible( true );
+
+		// Call with empty array - should not cause any issues.
+		$method->invoke( null, array() );
+
+		// Explicit assertion that no exception was thrown.
+		$this->addToAssertionCount( 1 );
+	}
+
+	/**
+	 * @testdox Should clear queue after sending pixels.
+	 */
+	public function test_queue_cleared_after_sending() {
+		// Ensure batching is supported.
+		if ( ! class_exists( 'WpOrg\Requests\Requests' ) && ! class_exists( 'Requests' ) ) {
+			$this->markTestSkipped( 'Requests library not available for batching.' );
+		}
+
+		// Queue multiple pixels.
+		WC_Tracks_Client::record_pixel_batched( 'https://pixel.wp.com/t.gif?_en=event1' );
+		WC_Tracks_Client::record_pixel_batched( 'https://pixel.wp.com/t.gif?_en=event2' );
+		WC_Tracks_Client::record_pixel_batched( 'https://pixel.wp.com/t.gif?_en=event3' );
+
+		$queue = $this->get_batch_queue();
+		$this->assertCount( 3, $queue );
+
+		// Send batched pixels.
+		WC_Tracks_Client::send_batched_pixels();
+
+		// Queue should be empty.
+		$queue = $this->get_batch_queue();
+		$this->assertEmpty( $queue );
+	}
+
+	/**
+	 * @testdox Should batch multiple pixels together and clear queue after sending.
+	 */
+	public function test_multiple_pixels_batched_together() {
+		// Ensure batching is supported.
+		if ( ! class_exists( 'WpOrg\Requests\Requests' ) && ! class_exists( 'Requests' ) ) {
+			$this->markTestSkipped( 'Requests library not available for batching.' );
+		}
+
+		// Record multiple pixels.
+		WC_Tracks_Client::record_pixel_batched( 'https://pixel.wp.com/t.gif?_en=event1&prop=value1' );
+		WC_Tracks_Client::record_pixel_batched( 'https://pixel.wp.com/t.gif?_en=event2&prop=value2' );
+
+		// Verify both pixels are in the queue before sending.
+		$queue = $this->get_batch_queue();
+		$this->assertCount( 2, $queue, 'Should have 2 pixels queued.' );
+
+		// Send batched pixels.
+		WC_Tracks_Client::send_batched_pixels();
+
+		// Verify queue is cleared after sending.
+		$queue = $this->get_batch_queue();
+		$this->assertEmpty( $queue, 'Queue should be empty after sending.' );
+	}
+
+	/**
+	 * @testdox Should intercept fallback requests when batching is disabled.
+	 */
+	public function test_fallback_requests_are_intercepted() {
+		// Disable batching via filter.
+		add_filter( 'wc_tracks_use_batch_requests', '__return_false' );
+
+		// Clear intercepted requests.
+		$this->intercepted_requests = array();
+
+		// Record a pixel (should use fallback method).
+		WC_Tracks_Client::record_pixel_batched( 'https://pixel.wp.com/t.gif?_en=fallback_event' );
+
+		// Verify that HTTP request was intercepted.
+		$this->assertCount( 1, $this->intercepted_requests, 'Should have intercepted 1 HTTP request.' );
+		$this->assertStringContainsString( '_en=fallback_event', $this->intercepted_requests[0]['url'] );
+
+		// Clean up filter.
+		remove_filter( 'wc_tracks_use_batch_requests', '__return_false' );
+	}
+}