Commit 22e5c9523cf for woocommerce
commit 22e5c9523cffbe63b764bfc7d4230468d5a4a528
Author: Vlad Olaru <vlad.olaru@automattic.com>
Date: Mon Aug 31 18:33:24 2026 +0300
Retry failed WC Tracker snapshots on the next scheduled run (#68120)
* fix: Retry failed tracker snapshots on the next scheduled run
WC_Tracker stamped woocommerce_tracker_last_send before posting the
snapshot and posted with blocking => false, so the response was never
inspected. Any transient network or server failure silently lost that
week's snapshot, because the daily tracker action found last_send fresh
and skipped sending for the rest of the week.
Make the request blocking with a 10 second timeout and record last_send
only once the snapshot is accepted with a 2xx status. A transient
failure (transport error, 408/425/429, 5xx) now leaves the snapshot
pending, and the existing daily tracker action retries it on its next
run. Consecutive failures are counted in woocommerce_tracker_send_failures
and the snapshot is abandoned after three, or immediately when the server
rejects it with another 4xx, so a persistent outage does not retry
forever. Failures are logged under the woocommerce-tracker source without
the payload.
The request now waits for the response, bounded by the timeout, where it
previously did not wait for one; callers that run it synchronously from
an admin request should expect that.
No public signature, hook or scheduling change is needed: the daily
Action Scheduler action already provides the retry cadence. The PHPStan
baseline entry for the old request arguments no longer matches and is
removed rather than replaced, as the false return of wp_json_encode is
now handled explicitly.
Refs #38574
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix: Keep the tracker override guard and clean up retry state
Recording woocommerce_tracker_last_send only on a successful delivery
weakened the one hour guard between override sends: after a failed
attempt, a second click within the hour posted the snapshot again. The
guard exists specifically to stop double-click duplicates, and the
tracking service does not deduplicate on its side.
Record woocommerce_tracker_last_attempt before every send and make the
override guard use the later of the attempt time and the recorded send
time, so overlapping or repeated override sends are suppressed exactly
as before while the weekly gate still keys on successful delivery.
Also log the posted snapshot size with each failure and name the case
where the service rejects the snapshot as too large (HTTP 413), since
that is a persistent failure that retrying cannot fix, and discard the
consecutive failure counter when usage tracking is turned off so it
does not carry over to a later opt-in.
Refs #38574
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix: Record tracker encode failures and respect opt-out mid-request
A snapshot that wp_json_encode() cannot encode, for example because a
woocommerce_tracker_data filter injected INF or NAN, returned before
any bookkeeping. With the send time only recorded on success, such a
snapshot was rebuilt and dropped on every daily run with no log entry
and no cap. Route it through the shared failure path as a non-retryable
failure so it is logged once and skipped until the next interval.
Also stop creating the consecutive failure counter when usage tracking
was turned off while a request was in flight, so opting out does not
leave retry state behind.
Refs #38574
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix: Bound tracker retries for any truthy usage-tracking value
`WC_Tracker::record_send_failure()` persisted the consecutive-failure
counter only when `woocommerce_allow_tracking` held the literal string
`'yes'`, while every other tracking-on decision in core resolves that
option through `wc_string_to_bool()`.
`wc_string_to_bool()` also accepts `1`, `'1'` and `'true'`, and the
Admin Options REST endpoint carries `woocommerce_allow_tracking` on
its frozen legacy allowlist while passing the raw decoded JSON value
straight to `update_option()`. A body of
`{"woocommerce_allow_tracking": true}` therefore stores `'1'`. On such
a site the send still ran, but the counter was never written: the
failure count recomputed from zero on every run, so
`MAX_CONSECUTIVE_SEND_FAILURES` was never reached and no send time was
recorded for a retryable failure. The daily action then rebuilt and
re-posted the full snapshot every day for the whole duration of an
outage, unbounded — defeating the retry cap this branch exists to add.
Resolve the option the way the rest of core does. The accompanying
test runs three consecutive retryable failures end to end without
seeding the counter, over both `'yes'` and `'1'`; every pre-existing
give-up test seeded the counter first and so never reached this
branch.
Refs #38574
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* test: Stop the tracker pending-snapshot test straddling a second
`test_retryable_failure_keeps_snapshot_pending()` evaluated
`strtotime( '-2 weeks' )` twice: once to seed
`woocommerce_tracker_last_send`, and again in the assertion that the
option is unchanged.
`send_tracking_data()` runs between those two calls, and it builds the
whole snapshot through `get_tracking_data()` — a multi-query aggregate
pass. Whenever that work straddles a second boundary the two
timestamps differ by one and the assertion fails, so the test command
this branch documents did not pass reliably. Repeating the method ten
times over its six data sets reproduced three failures in sixty
executions, every one an off-by-one at that assertion.
Capture the timestamp once and assert against the captured value. The
other new tests already compare against `time()` through
`assertEqualsWithDelta( ..., 5 )`, so this is the only method with the
double-evaluation shape.
Refs #38574
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix: Record the tracker attempt time before building the snapshot
The one-hour guard between override sends returns early on the later
of `woocommerce_tracker_last_send` and
`woocommerce_tracker_last_attempt`, but the `last_attempt` write it
depends on happened after the snapshot was built.
Before this branch, the equivalent write was the statement
immediately after the guard, so the read-check-write window was one
statement wide. Moving it below `get_tracking_data()` put an entire
multi-query aggregate pass inside that window. Two override calls
racing — a double-submitted form, or two tabs finishing the connect
flow together — could both read the same stale timestamp, both pass
the guard, and both post. That is exactly the duplicate the guard was
added to prevent.
Write the attempt time before building the snapshot, restoring the
original window width. This narrows the race rather than closing it:
two genuinely concurrent requests can still both pass, and the cost
of that is a duplicate snapshot the tracking service tolerates. The
encode-failure path now also stamps the attempt time, which is
harmless — it gives up and records a send time regardless, and the
guard reads the later of the two.
Refs #38574
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* docs: Correct the record_send_result() send time docblock
The docblock said the send time is recorded only once the snapshot is
accepted, but `record_send_failure()` also writes
`woocommerce_tracker_last_send` whenever it gives up — on a 413, an
encode failure, or the third consecutive failure.
That behaviour is deliberate: stamping the send time on abandonment
re-arms the weekly interval so a snapshot that cannot succeed does not
retry forever. Only the description was wrong, so correct the wording.
Refs #38574
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
diff --git a/plugins/woocommerce/changelog/fix-38574-tracker-retry-snapshots b/plugins/woocommerce/changelog/fix-38574-tracker-retry-snapshots
new file mode 100644
index 00000000000..fd75e9029a0
--- /dev/null
+++ b/plugins/woocommerce/changelog/fix-38574-tracker-retry-snapshots
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Retry failed usage-tracking snapshot deliveries on the next scheduled run instead of silently dropping them for a week.
diff --git a/plugins/woocommerce/includes/class-wc-tracker.php b/plugins/woocommerce/includes/class-wc-tracker.php
index 97013e96567..63e5f4cb46c 100644
--- a/plugins/woocommerce/includes/class-wc-tracker.php
+++ b/plugins/woocommerce/includes/class-wc-tracker.php
@@ -38,6 +38,15 @@ class WC_Tracker {
*/
private static $api_url = 'https://tracking.woocommerce.com/v1/';
+ /**
+ * Consecutive failed deliveries after which the current snapshot is abandoned.
+ *
+ * Retries happen on the daily tracker action, so this bounds retrying to a few days.
+ *
+ * @var int
+ */
+ private const MAX_CONSECUTIVE_SEND_FAILURES = 3;
+
/**
* Hook into cron event.
*/
@@ -69,29 +78,116 @@ class WC_Tracker {
}
} else {
// Make sure there is at least a 1 hour delay between override sends, we don't want duplicate calls due to double clicking links.
- $last_send = self::get_last_send_time();
- if ( $last_send && $last_send > strtotime( '-1 hours' ) ) {
+ $last_attempt = max( (int) self::get_last_send_time(), (int) get_option( 'woocommerce_tracker_last_attempt', 0 ) );
+ if ( $last_attempt > strtotime( '-1 hours' ) ) {
return;
}
}
- // Update time first before sending to ensure it is set.
- update_option( 'woocommerce_tracker_last_send', time() );
+ // Recorded before building the snapshot so overlapping override sends are still suppressed.
+ update_option( 'woocommerce_tracker_last_attempt', time(), false );
- $params = self::get_tracking_data();
- wp_safe_remote_post(
+ $body = wp_json_encode( self::get_tracking_data() );
+ if ( false === $body ) {
+ self::record_send_failure( false, 0, 'json_encode_failure', 0 );
+ return;
+ }
+
+ $response = wp_safe_remote_post(
self::$api_url,
array(
'method' => 'POST',
- 'timeout' => 45,
+ 'timeout' => 10,
'redirection' => 5,
'httpversion' => '1.0',
- 'blocking' => false,
+ 'blocking' => true,
'headers' => array( 'user-agent' => 'WooCommerceTracker/' . md5( esc_url_raw( home_url( '/' ) ) ) . ';' ),
- 'body' => wp_json_encode( $params ),
+ 'body' => $body,
'cookies' => array(),
)
);
+
+ self::record_send_result( $response, strlen( $body ) );
+ }
+
+ /**
+ * Record the outcome of a delivery attempt.
+ *
+ * The send time is recorded after acceptance or permanent abandonment. A transient failure
+ * remains pending for the next run.
+ *
+ * @param array|WP_Error $response Response from wp_safe_remote_post().
+ * @param int $body_bytes Size of the posted snapshot.
+ */
+ private static function record_send_result( $response, $body_bytes ): void {
+ $status = is_wp_error( $response ) ? 0 : (int) wp_remote_retrieve_response_code( $response );
+
+ if ( 200 <= $status && 300 > $status ) {
+ update_option( 'woocommerce_tracker_last_send', time() );
+ delete_option( 'woocommerce_tracker_send_failures' );
+ return;
+ }
+
+ self::record_send_failure( self::is_retryable_status( $status ), $status, is_wp_error( $response ) ? (string) $response->get_error_code() : '', $body_bytes );
+ }
+
+ /**
+ * Record a failed delivery attempt.
+ *
+ * Consecutive retryable failures are counted so a persistent outage does not retry forever;
+ * a non-retryable failure or the last allowed attempt gives up on the current snapshot.
+ * Retry state is only kept while tracking is still enabled, since opting out may have
+ * happened while the request was in flight.
+ *
+ * @param bool $retryable Whether the next scheduled run should try again.
+ * @param int $status HTTP status code, 0 when no response was received.
+ * @param string $error_code Error code when no response was received.
+ * @param int $body_bytes Size of the snapshot.
+ */
+ private static function record_send_failure( $retryable, $status, $error_code, $body_bytes ): void {
+ $failures = (int) get_option( 'woocommerce_tracker_send_failures', 0 ) + 1;
+ $give_up = ! $retryable || self::MAX_CONSECUTIVE_SEND_FAILURES <= $failures;
+
+ if ( $give_up ) {
+ update_option( 'woocommerce_tracker_last_send', time() );
+ delete_option( 'woocommerce_tracker_send_failures' );
+ } elseif ( true === wc_string_to_bool( get_option( 'woocommerce_allow_tracking', 'no' ) ) ) {
+ update_option( 'woocommerce_tracker_send_failures', $failures, false );
+ }
+
+ if ( 413 === $status ) {
+ $message = 'WooCommerce tracker snapshot delivery failed; the snapshot is too large for the service and will not be retried.';
+ } elseif ( 'json_encode_failure' === $error_code ) {
+ $message = 'WooCommerce tracker snapshot could not be encoded and will not be retried.';
+ } elseif ( $give_up ) {
+ $message = 'WooCommerce tracker snapshot delivery failed; giving up until the next interval.';
+ } else {
+ $message = 'WooCommerce tracker snapshot delivery failed; it will be retried on the next run.';
+ }
+
+ wc_get_logger()->warning(
+ $message,
+ array(
+ 'source' => 'woocommerce-tracker',
+ 'http_status' => $status,
+ 'error_code' => $error_code,
+ 'failures' => $failures,
+ 'body_bytes' => $body_bytes,
+ )
+ );
+ }
+
+ /**
+ * Whether a delivery failure with the given status is worth retrying.
+ *
+ * Transport failures (status 0), rate limiting, timeouts and server errors are retried;
+ * other client errors mean the snapshot itself was rejected.
+ *
+ * @param int $status HTTP status code, 0 for a transport failure.
+ * @return bool
+ */
+ private static function is_retryable_status( $status ) {
+ return 0 === $status || in_array( $status, array( 408, 425, 429 ), true ) || 500 <= $status;
}
/**
diff --git a/plugins/woocommerce/includes/class-woocommerce.php b/plugins/woocommerce/includes/class-woocommerce.php
index 0c1ed0361c2..e91dcef4100 100644
--- a/plugins/woocommerce/includes/class-woocommerce.php
+++ b/plugins/woocommerce/includes/class-woocommerce.php
@@ -1805,6 +1805,7 @@ final class WooCommerce {
}
if ( false === wc_string_to_bool( $value ) ) {
as_unschedule_all_actions( 'woocommerce_tracker_send_event_wrapper', array(), 'woocommerce' );
+ delete_option( 'woocommerce_tracker_send_failures' );
} else {
$this->schedule_tracking_action();
}
diff --git a/plugins/woocommerce/phpstan-baseline.neon b/plugins/woocommerce/phpstan-baseline.neon
index 728372b7001..f4fefdc105b 100644
--- a/plugins/woocommerce/phpstan-baseline.neon
+++ b/plugins/woocommerce/phpstan-baseline.neon
@@ -15828,12 +15828,6 @@ parameters:
count: 1
path: includes/class-wc-tracker.php
- -
- message: '#^Parameter \#2 \$args of function wp_safe_remote_post expects array\{method\?\: string, timeout\?\: float, redirection\?\: int, httpversion\?\: string, user\-agent\?\: string, reject_unsafe_urls\?\: bool, blocking\?\: bool, headers\?\: array\|string, \.\.\.\}, array\{method\: ''POST'', timeout\: 45, redirection\: 5, httpversion\: ''1\.0'', blocking\: false, headers\: array\{user\-agent\: non\-falsy\-string\}, body\: non\-empty\-string\|false, cookies\: array\{\}\} given\.$#'
- identifier: argument.type
- count: 1
- path: includes/class-wc-tracker.php
-
-
message: '#^Property WC_Shipping_Method\:\:\$enabled \(string\) in isset\(\) is not nullable\.$#'
identifier: isset.property
diff --git a/plugins/woocommerce/tests/php/includes/class-wc-tracker-test.php b/plugins/woocommerce/tests/php/includes/class-wc-tracker-test.php
index d9213e4e30f..08a6fb04a03 100644
--- a/plugins/woocommerce/tests/php/includes/class-wc-tracker-test.php
+++ b/plugins/woocommerce/tests/php/includes/class-wc-tracker-test.php
@@ -98,6 +98,329 @@ class WC_Tracker_Test extends \WC_Unit_Test_Case {
$this->assertEquals( 'no', $tracking_data['wc_admin_disabled'] );
}
+ /**
+ * @testdox Should send a blocking request with a short timeout so the response can be inspected.
+ */
+ public function test_send_tracking_data_uses_blocking_request(): void {
+ $request_args = null;
+ $this->fake_tracker_response( 200, $request_args );
+
+ WC_Tracker::send_tracking_data( true );
+
+ $this->assertTrue( $request_args['blocking'], 'The tracker request must be blocking to read the response.' );
+ $this->assertSame( 10, $request_args['timeout'], 'The tracker request timeout should be short.' );
+ }
+
+ /**
+ * @testdox Should record the send time and clear the failure counter after a successful delivery.
+ */
+ public function test_successful_send_records_last_send_and_clears_failures(): void {
+ update_option( 'woocommerce_tracker_send_failures', 2 );
+ $this->fake_tracker_response( 204 );
+
+ WC_Tracker::send_tracking_data( true );
+
+ $this->assertEqualsWithDelta( time(), (int) get_option( 'woocommerce_tracker_last_send' ), 5, 'A 2xx response should record the send time.' );
+ $this->assertFalse( get_option( 'woocommerce_tracker_send_failures' ), 'A 2xx response should clear the failure counter.' );
+ }
+
+ /**
+ * @testdox Should not record the send time and should count the failure when delivery fails with a retryable error.
+ *
+ * @testWith [500]
+ * [503]
+ * [429]
+ * [408]
+ * [425]
+ * ["wp_error"]
+ *
+ * @param int|string $status HTTP status, or "wp_error" for a transport failure.
+ */
+ public function test_retryable_failure_keeps_snapshot_pending( $status ): void {
+ update_option( 'woocommerce_allow_tracking', 'yes' );
+ $last_send = strtotime( '-2 weeks' );
+ update_option( 'woocommerce_tracker_last_send', $last_send );
+ $this->fake_tracker_response( $status );
+ $logger = $this->expect_tracker_warning();
+
+ WC_Tracker::send_tracking_data();
+
+ $this->assertSame( $last_send, (int) get_option( 'woocommerce_tracker_last_send' ), 'A retryable failure must leave the last send time unchanged so the next daily run retries.' );
+ $this->assertSame( 1, (int) get_option( 'woocommerce_tracker_send_failures' ), 'A retryable failure should increment the failure counter.' );
+ $this->assertCount( 1, $logger->warnings, 'A failed delivery should be logged as a warning.' );
+ $this->assertSame( 'woocommerce-tracker', $logger->warnings[0]['source'] );
+ }
+
+ /**
+ * @testdox Should give up on the snapshot when the server rejects it with a non-retryable status.
+ *
+ * @testWith [400]
+ * [403]
+ * [404]
+ * [410]
+ *
+ * @param int $status HTTP status.
+ */
+ public function test_non_retryable_failure_records_last_send( int $status ): void {
+ update_option( 'woocommerce_tracker_send_failures', 1 );
+ $this->fake_tracker_response( $status );
+ $this->expect_tracker_warning();
+
+ WC_Tracker::send_tracking_data( true );
+
+ $this->assertEqualsWithDelta( time(), (int) get_option( 'woocommerce_tracker_last_send' ), 5, 'A non-retryable failure should record the send time so the snapshot is not retried.' );
+ $this->assertFalse( get_option( 'woocommerce_tracker_send_failures' ), 'Giving up should clear the failure counter.' );
+ }
+
+ /**
+ * @testdox Should give up on the snapshot after the maximum number of consecutive failures.
+ */
+ public function test_max_consecutive_failures_records_last_send(): void {
+ update_option( 'woocommerce_tracker_send_failures', 2 );
+ $this->fake_tracker_response( 503 );
+ $this->expect_tracker_warning();
+
+ WC_Tracker::send_tracking_data( true );
+
+ $this->assertEqualsWithDelta( time(), (int) get_option( 'woocommerce_tracker_last_send' ), 5, 'The third consecutive failure should record the send time.' );
+ $this->assertFalse( get_option( 'woocommerce_tracker_send_failures' ), 'Giving up should clear the failure counter.' );
+ }
+
+ /**
+ * @testdox Should bound consecutive failures for any truthy value of the tracking option.
+ *
+ * @testWith ["yes"]
+ * ["1"]
+ *
+ * @param string $allow_tracking Stored value of the tracking option.
+ */
+ public function test_consecutive_failures_are_bounded_for_any_truthy_tracking_value( string $allow_tracking ): void {
+ update_option( 'woocommerce_allow_tracking', $allow_tracking );
+ $requests = 0;
+ add_filter(
+ 'pre_http_request',
+ function () use ( &$requests ) {
+ ++$requests;
+ return array( 'response' => array( 'code' => 503 ) );
+ }
+ );
+ $this->expect_tracker_warning();
+
+ WC_Tracker::send_tracking_data();
+ $this->assertSame( 1, (int) get_option( 'woocommerce_tracker_send_failures' ), 'The first failure should persist a count of one.' );
+
+ WC_Tracker::send_tracking_data();
+ $this->assertSame( 2, (int) get_option( 'woocommerce_tracker_send_failures' ), 'The second failure should persist a count of two.' );
+
+ WC_Tracker::send_tracking_data();
+
+ $this->assertSame( 3, $requests, 'Each daily run should retry while the snapshot is still pending.' );
+ $this->assertEqualsWithDelta( time(), (int) get_option( 'woocommerce_tracker_last_send' ), 5, 'The third consecutive failure should record the send time so the snapshot is abandoned.' );
+ $this->assertFalse( get_option( 'woocommerce_tracker_send_failures' ), 'Giving up should clear the failure counter.' );
+ }
+
+ /**
+ * @testdox Should retry on the next daily run after a failed delivery without waiting for the weekly interval.
+ */
+ public function test_failed_send_is_retried_on_next_run(): void {
+ $requests = 0;
+ add_filter(
+ 'pre_http_request',
+ function () use ( &$requests ) {
+ ++$requests;
+ return 1 === $requests ? new WP_Error( 'http_request_failed', 'Timed out' ) : array( 'response' => array( 'code' => 200 ) );
+ }
+ );
+ $this->expect_tracker_warning();
+
+ WC_Tracker::send_tracking_data();
+ WC_Tracker::send_tracking_data();
+
+ $this->assertSame( 2, $requests, 'The second run should retry because the first delivery did not record a send time.' );
+ $this->assertEqualsWithDelta( time(), (int) get_option( 'woocommerce_tracker_last_send' ), 5 );
+ }
+
+ /**
+ * @testdox Should suppress a second override send within an hour of a failed attempt.
+ */
+ public function test_override_send_is_suppressed_within_an_hour_of_a_failed_attempt(): void {
+ $request_args = null;
+ $requests = 0;
+ add_filter(
+ 'pre_http_request',
+ function () use ( &$requests ) {
+ ++$requests;
+ return new WP_Error( 'http_request_failed', 'Timed out' );
+ }
+ );
+ $this->expect_tracker_warning();
+
+ WC_Tracker::send_tracking_data( true );
+ WC_Tracker::send_tracking_data( true );
+
+ $this->assertSame( 1, $requests, 'A failed override send must still block a second override send within the hour.' );
+ $this->assertEqualsWithDelta( time(), (int) get_option( 'woocommerce_tracker_last_attempt' ), 5, 'Every attempt should record its time.' );
+ $this->assertFalse( get_option( 'woocommerce_tracker_last_send' ), 'A failed attempt must not record a send time.' );
+ }
+
+ /**
+ * @testdox Should include the payload size in the failure log.
+ */
+ public function test_failure_log_includes_payload_size(): void {
+ $this->fake_tracker_response( 503 );
+ $logger = $this->expect_tracker_warning();
+
+ WC_Tracker::send_tracking_data( true );
+
+ $this->assertGreaterThan( 0, $logger->warnings[0]['body_bytes'], 'The failure log should report the payload size.' );
+ }
+
+ /**
+ * @testdox Should report a rejected oversized snapshot distinctly.
+ */
+ public function test_too_large_snapshot_is_logged_as_such(): void {
+ $this->fake_tracker_response( 413 );
+ $logger = $this->expect_tracker_warning();
+
+ WC_Tracker::send_tracking_data( true );
+
+ $this->assertStringContainsString( 'too large', $logger->messages[0] );
+ $this->assertEqualsWithDelta( time(), (int) get_option( 'woocommerce_tracker_last_send' ), 5, 'An oversized snapshot is not retried.' );
+ }
+
+ /**
+ * @testdox Should clear the failure counter when tracking is turned off.
+ */
+ public function test_opting_out_clears_failure_counter(): void {
+ update_option( 'woocommerce_tracker_send_failures', 2 );
+
+ WC()->handle_tracking_setting_change( 'yes', 'no' );
+
+ $this->assertFalse( get_option( 'woocommerce_tracker_send_failures' ), 'Opting out should discard pending retry state.' );
+ }
+
+ /**
+ * @testdox Should treat a snapshot that cannot be encoded as a non-retryable failure.
+ */
+ public function test_unencodable_snapshot_is_logged_and_not_retried(): void {
+ $requests = 0;
+ add_filter(
+ 'pre_http_request',
+ function () use ( &$requests ) {
+ ++$requests;
+ return array( 'response' => array( 'code' => 200 ) );
+ }
+ );
+ add_filter(
+ 'woocommerce_tracker_data',
+ function ( $data ) {
+ $data['unencodable'] = INF;
+ return $data;
+ }
+ );
+ update_option( 'woocommerce_tracker_send_failures', 1 );
+ $logger = $this->expect_tracker_warning();
+
+ WC_Tracker::send_tracking_data( true );
+
+ $this->assertSame( 0, $requests, 'An unencodable snapshot must not be posted.' );
+ $this->assertSame( 'json_encode_failure', $logger->warnings[0]['error_code'] );
+ $this->assertEqualsWithDelta( time(), (int) get_option( 'woocommerce_tracker_last_send' ), 5, 'An unencodable snapshot should not be retried daily.' );
+ $this->assertFalse( get_option( 'woocommerce_tracker_send_failures' ), 'Giving up should clear the failure counter.' );
+ }
+
+ /**
+ * @testdox Should not record retry state when tracking was turned off while the request was in flight.
+ */
+ public function test_retry_state_is_not_recorded_after_opt_out_during_request(): void {
+ update_option( 'woocommerce_allow_tracking', 'yes' );
+ add_filter(
+ 'pre_http_request',
+ function () {
+ update_option( 'woocommerce_allow_tracking', 'no' );
+ return array( 'response' => array( 'code' => 503 ) );
+ }
+ );
+ $this->expect_tracker_warning();
+
+ WC_Tracker::send_tracking_data( true );
+
+ $this->assertFalse( get_option( 'woocommerce_tracker_send_failures' ), 'Retry state must not be created once tracking is off.' );
+ }
+
+ /**
+ * Fake the tracker HTTP response.
+ *
+ * @param int|string $status HTTP status code, or "wp_error" for a transport failure.
+ * @param array|null $request_args Receives the request arguments by reference.
+ */
+ private function fake_tracker_response( $status, &$request_args = null ): void {
+ add_filter(
+ 'pre_http_request',
+ function ( $pre, $args ) use ( $status, &$request_args ) {
+ $request_args = $args;
+ if ( 'wp_error' === $status ) {
+ return new WP_Error( 'http_request_failed', 'Timed out' );
+ }
+ return array(
+ 'headers' => array(),
+ 'body' => '',
+ 'response' => array(
+ 'code' => $status,
+ 'message' => '',
+ ),
+ );
+ },
+ 10,
+ 2
+ );
+ }
+
+ /**
+ * Inject a fake logger that records warning contexts.
+ *
+ * @return object Fake logger with public `warnings` (contexts) and `messages` arrays.
+ */
+ private function expect_tracker_warning() {
+ $logger = new class() implements WC_Logger_Interface {
+ /**
+ * Recorded warning contexts.
+ *
+ * @var array
+ */
+ public $warnings = array();
+
+ /**
+ * Recorded warning messages.
+ *
+ * @var array
+ */
+ public $messages = array();
+
+ // phpcs:disable Squiz.Commenting.FunctionComment.Missing, Generic.CodeAnalysis.UnusedFunctionParameter.Found
+ public function add( $handle, $message, $level = WC_Log_Levels::NOTICE ) {}
+ public function log( $level, $message, $context = array() ) {}
+ public function emergency( $message, $context = array() ) {}
+ public function alert( $message, $context = array() ) {}
+ public function critical( $message, $context = array() ) {}
+ public function error( $message, $context = array() ) {}
+ public function warning( $message, $context = array() ) {
+ $this->warnings[] = $context;
+ $this->messages[] = $message;
+ }
+ public function notice( $message, $context = array() ) {}
+ public function info( $message, $context = array() ) {}
+ public function debug( $message, $context = array() ) {}
+ // phpcs:enable
+ };
+ add_filter(
+ 'woocommerce_logging_class',
+ static function () use ( $logger ) {
+ return $logger;
+ }
+ );
+ return $logger;
+ }
/**
* @testDox Test the features compatibility data for plugin tracking data.
*/