Commit fff77b7a0e6 for woocommerce

commit fff77b7a0e60b603b01590a835f15c9f74c0eea0
Author: Vlad Olaru <vlad.olaru@automattic.com>
Date:   Thu Oct 15 09:35:03 2026 +0300

    Opt Store API engine errors back into remote logging (#69585)

    * fix(store-api): Opt engine errors back into remote logging

    Before #68577, a TypeError or other non-Exception Throwable in a Store
    API route escaped as a PHP fatal. WooCommerce::log_errors() then logged
    it under the fatal-errors source with remote-logging enabled, so stores
    that opted into tracking sent it to the remote logging service. The new
    catch arms log the failure under the store-api source without that
    flag, and RemoteLogger::should_handle() drops every entry that does not
    carry it. The remote signal for these errors silently went away.

    Add the remote-logging flag and the error file and line to the log
    context UnexpectedErrorResponse::create() writes, so the remote record
    gets its file field the way fatal records do. Apply RemoteLogger's
    third-party error check to the store-api source as well as
    fatal-errors, since both carry the same backtrace shape; without it
    every extension-caused Store API error would reach the remote feed,
    where the fatal path filtered them out.

    Verified end to end on a local store with tracking enabled: an error
    thrown inside a third-party plugin's own function is dropped, and an
    error raised in WooCommerce code or by a callback on a WooCommerce hook
    is sent, which is the same classification the fatal path produced for
    each. The record itself differs from the old fatal record: the message
    starts with "Store API request failed in <route>" rather than
    "Uncaught", the source is store-api, there is no error type, and the
    trace is capped at ten frames.

    Refs #47095

    Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

    * fix(logging): Limit what a caught Store API error sends and counts

    Two follow-ups to opting Store API engine errors into remote logging.

    The log context carried the Throwable under the PSR-3 'exception' key.
    WooCommerce's own handlers write it as an empty object, but once the
    entry is sent, RemoteLogger::get_formatted_log() merges every leftover
    context key into 'extra' without redaction, and json_encode emits a
    throwable's public properties. A third-party error class holding an
    email address or a token would send it off the site raw. The fatal path
    never carried an exception object. Drop the key; the message, 'error'
    and 'backtrace' already carry everything it held.

    RemoteLogger::should_handle() pinged the critical-errors stat for every
    critical entry. That stat was scoped in #51363 to count WooCommerce
    core fatals, and the ping is a blocking wp_remote_get() with the
    default five-second timeout. For a fatal it ran once on a request that
    was already over; a caught Store API error keeps the request alive and
    can log once per sub-request in a batch, so the shopper would have
    waited for up to 25 pings. Record the stat only for the fatal-errors
    source, so the counter keeps meaning what it always has and the ping
    never runs inside a shopper's request.

    Tests: a case runs the logged context through get_formatted_log() and
    the third-party check, asserting the record keeps the file and the
    trace and contains nothing from a throwable's public state. The
    third-party provider gains the message-contains-WooCommerce branch for
    the store-api source and uses real backtrace frame strings. The filter
    docblock now names both sources and the context shape of each.

    Refs #47095

    Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

    * test: Drop the vacuous third-party check from the Store API record test

    The remote record test asserted that is_third_party_error() returns
    false for a Store API error raised inside WooCommerce. That assertion
    passes even when 'store-api' is missing from THIRD_PARTY_CHECK_SOURCES,
    because the method returns false at its first guard for any source
    outside the list. It could never catch the regression it implied.

    RemoteLoggerTest already pins the wiring: its "Store API third-party
    backtrace" case expects true, and fails when 'store-api' is removed
    from the list.

    Refs #47095

    Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

    ---------

    Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>

diff --git a/plugins/woocommerce/changelog/fix-47095-store-api-remote-logging b/plugins/woocommerce/changelog/fix-47095-store-api-remote-logging
new file mode 100644
index 00000000000..7f72c759444
--- /dev/null
+++ b/plugins/woocommerce/changelog/fix-47095-store-api-remote-logging
@@ -0,0 +1,4 @@
+Significance: patch
+Type: dev
+Comment: Opt Store API engine errors back into remote logging, with the third-party error check applied; no merchant-visible change.
+
diff --git a/plugins/woocommerce/src/Internal/Logging/RemoteLogger.php b/plugins/woocommerce/src/Internal/Logging/RemoteLogger.php
index afe55ad2735..109888b3d11 100644
--- a/plugins/woocommerce/src/Internal/Logging/RemoteLogger.php
+++ b/plugins/woocommerce/src/Internal/Logging/RemoteLogger.php
@@ -29,6 +29,11 @@ class RemoteLogger extends \WC_Log_Handler {
 	const RATE_LIMIT_DELAY         = 60; // 1 minute.
 	const WC_NEW_VERSION_TRANSIENT = 'woocommerce_new_version';

+	/**
+	 * Log sources whose entries carry an engine-error backtrace and go through the third-party error check.
+	 */
+	private const THIRD_PARTY_CHECK_SOURCES = array( 'fatal-errors', 'store-api' );
+
 	/**
 	 * Handle a log entry.
 	 *
@@ -182,8 +187,8 @@ class RemoteLogger extends \WC_Log_Handler {
 			return false;
 		}

-		// Record fatal error stats.
-		if ( WC_Log_Levels::get_level_severity( $level ) >= WC_Log_Levels::get_level_severity( WC_Log_Levels::CRITICAL ) ) {
+		// Record fatal error stats. Caught Store API errors are not fatals, and the stats ping is a blocking request that would otherwise run once per error inside the shopper's request.
+		if ( 'fatal-errors' === ( $context['source'] ?? '' ) && WC_Log_Levels::get_level_severity( $level ) >= WC_Log_Levels::get_level_severity( WC_Log_Levels::CRITICAL ) ) {
 			try {
 				$mc_stats = wc_get_container()->get( McStats::class );
 				$mc_stats->add( 'error', 'critical-errors' );
@@ -310,7 +315,7 @@ class RemoteLogger extends \WC_Log_Handler {
 	}

 	/**
-	 * Check if the error exclusively contains third-party stack frames for fatal-errors source context.
+	 * Check if the error exclusively contains third-party stack frames, for the sources that carry an engine-error backtrace.
 	 *
 	 * @param string $message The error message.
 	 * @param array  $context The error context.
@@ -318,8 +323,7 @@ class RemoteLogger extends \WC_Log_Handler {
 	 * @return bool
 	 */
 	protected function is_third_party_error( string $message, array $context ): bool {
-		// Only check for fatal-errors source context.
-		if ( ! isset( $context['source'] ) || 'fatal-errors' !== $context['source'] ) {
+		if ( ! isset( $context['source'] ) || ! in_array( $context['source'], self::THIRD_PARTY_CHECK_SOURCES, true ) ) {
 			return false;
 		}

@@ -363,11 +367,13 @@ class RemoteLogger extends \WC_Log_Handler {
 		/**
 		 * Filter to allow other plugins to overwrite the result of the third-party error check for remote logging.
 		 *
+		 * Runs for `fatal-errors` entries (from the shutdown handler, with `error.type`, `error.file` and `error.line`) and, since 11.3.0, for `store-api` entries (caught Store API engine errors, with `error.file` and `error.line` only). Both carry a `backtrace` list of `#N file(line): call` strings.
+		 *
 		 * @since 9.2.0
 		 *
 		 * @param bool   $is_third_party_error The result of the third-party error check.
 		 * @param string $message              The error message.
-		 * @param array  $context              The error context.
+		 * @param array  $context              The error context, including `source`.
 		 */
 		return apply_filters( 'woocommerce_remote_logging_is_third_party_error', true, $message, $context );
 	}
diff --git a/plugins/woocommerce/src/StoreApi/Utilities/UnexpectedErrorResponse.php b/plugins/woocommerce/src/StoreApi/Utilities/UnexpectedErrorResponse.php
index 16b7b85232a..6b594e38d4a 100644
--- a/plugins/woocommerce/src/StoreApi/Utilities/UnexpectedErrorResponse.php
+++ b/plugins/woocommerce/src/StoreApi/Utilities/UnexpectedErrorResponse.php
@@ -41,10 +41,15 @@ final class UnexpectedErrorResponse {
 			wc_get_logger()->critical(
 				$log_message,
 				array(
-					'source'    => 'store-api',
-					'exception' => $error,
+					'source'         => 'store-api',
+					'error'          => array(
+						'file' => $error->getFile(),
+						'line' => $error->getLine(),
+					),
 					// Same shape the fatal-error shutdown handler logs, so log handlers and readers see one trace format.
-					'backtrace' => array_slice( explode( "\n", $error->getTraceAsString() ), 0, self::MAX_BACKTRACE_FRAMES ),
+					'backtrace'      => array_slice( explode( "\n", $error->getTraceAsString() ), 0, self::MAX_BACKTRACE_FRAMES ),
+					// These failures reached remote logging as fatals before the dispatchers caught them.
+					'remote-logging' => true,
 				)
 			);
 		} catch ( \Throwable $logging_error ) {
diff --git a/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Utilities/UnexpectedErrorResponseTest.php b/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Utilities/UnexpectedErrorResponseTest.php
index ff1c10914c3..6d7d2b5dfa8 100644
--- a/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Utilities/UnexpectedErrorResponseTest.php
+++ b/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Utilities/UnexpectedErrorResponseTest.php
@@ -4,6 +4,7 @@ declare( strict_types = 1 );
 namespace Automattic\WooCommerce\Tests\Blocks\StoreApi\Utilities;

 use Automattic\Jetpack\Constants;
+use Automattic\WooCommerce\Internal\Logging\RemoteLogger;
 use Automattic\WooCommerce\StoreApi\Utilities\UnexpectedErrorResponse;
 use Automattic\WooCommerce\RestApi\UnitTests\LoggerSpyTrait;
 use WC_Logger_Interface;
@@ -58,7 +59,7 @@ class UnexpectedErrorResponseTest extends WC_Unit_Test_Case {
 	}

 	/**
-	 * @testdox Should log the engine failure as critical with a bounded backtrace.
+	 * @testdox Should log the engine failure as critical, opted into remote logging, with a bounded backtrace.
 	 */
 	public function test_logs_engine_failure_as_critical_with_bounded_backtrace(): void {
 		$error = new \TypeError( 'Fixture logged engine failure.' );
@@ -69,8 +70,12 @@ class UnexpectedErrorResponseTest extends WC_Unit_Test_Case {
 			'critical',
 			self::class,
 			array(
-				'source'    => 'store-api',
-				'exception' => $error,
+				'source'         => 'store-api',
+				'error'          => array(
+					'file' => $error->getFile(),
+					'line' => $error->getLine(),
+				),
+				'remote-logging' => true,
 			)
 		);
 		$this->assertCount( 1, $this->captured_logs );
@@ -78,13 +83,41 @@ class UnexpectedErrorResponseTest extends WC_Unit_Test_Case {
 		$this->assertStringContainsString( \TypeError::class, $message );
 		$this->assertStringContainsString( 'Fixture logged engine failure.', $message );

-		$backtrace = $this->captured_logs[0]['context']['backtrace'];
+		$context = $this->captured_logs[0]['context'];
+		$this->assertArrayNotHasKey( 'exception', $context, 'The error object must not be logged: remote logging would serialise its public properties unredacted.' );
+
+		$backtrace = $context['backtrace'];
 		$this->assertIsArray( $backtrace );
 		$this->assertGreaterThanOrEqual( 1, count( $backtrace ) );
 		$this->assertLessThanOrEqual( 10, count( $backtrace ) );
 		$this->assertStringStartsWith( '#0 ', $backtrace[0] );
 	}

+	/**
+	 * @testdox Should produce a remote log record that keeps the file and leaks nothing from the error object.
+	 */
+	public function test_logged_context_formats_into_a_remote_record(): void {
+		$error = new class('Fixture engine failure with state.') extends \TypeError {
+			/**
+			 * Public state a third-party error class might carry.
+			 *
+			 * @var string
+			 */
+			public $token = 'fixture-secret-token';
+		};
+
+		UnexpectedErrorResponse::create( $error, self::class );
+
+		$logged        = $this->captured_logs[0];
+		$remote_logger = wc_get_container()->get( RemoteLogger::class );
+		$record        = $remote_logger->get_formatted_log( 'critical', $logged['message'], $logged['context'] );
+
+		$this->assertSame( 'store-api', $record['extra']['source'] );
+		$this->assertStringContainsString( basename( __FILE__ ), $record['file'], 'The remote record must carry the file the error was raised in.' );
+		$this->assertStringStartsWith( '#0 ', $record['trace'] );
+		$this->assertStringNotContainsString( 'fixture-secret-token', wp_json_encode( $record ), 'Error object state must not reach the remote record.' );
+	}
+
 	/**
 	 * @testdox Should return a safe response and fall back to the PHP error log when logging fails.
 	 */
diff --git a/plugins/woocommerce/tests/php/src/Internal/Logging/RemoteLoggerTest.php b/plugins/woocommerce/tests/php/src/Internal/Logging/RemoteLoggerTest.php
index 4add9e455f2..0bdf2c672f6 100644
--- a/plugins/woocommerce/tests/php/src/Internal/Logging/RemoteLoggerTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/Logging/RemoteLoggerTest.php
@@ -513,7 +513,7 @@ namespace Automattic\WooCommerce\Tests\Internal\Logging {
 			$wp_admin_dir    = StringUtil::normalize_local_path_slashes( ABSPATH . 'wp-admin' );

 			return array(
-				'WooCommerce error message' => array(
+				'WooCommerce error message'           => array(
 					'Error in ' . $wc_plugin_dir . 'includes/class-wc-cart.php',
 					array(
 						'source'    => 'fatal-errors',
@@ -521,7 +521,7 @@ namespace Automattic\WooCommerce\Tests\Internal\Logging {
 					),
 					false,
 				),
-				'Third-party error message' => array(
+				'Third-party error message'           => array(
 					'Error in /plugins/some-other-plugin/file.php',
 					array(
 						'source'    => 'fatal-errors',
@@ -529,7 +529,7 @@ namespace Automattic\WooCommerce\Tests\Internal\Logging {
 					),
 					true,
 				),
-				'WooCommerce backtrace'     => array(
+				'WooCommerce backtrace'               => array(
 					'Some error message',
 					array(
 						'source'    => 'fatal-errors',
@@ -541,7 +541,7 @@ namespace Automattic\WooCommerce\Tests\Internal\Logging {
 					),
 					false,
 				),
-				'Third-party backtrace'     => array(
+				'Third-party backtrace'               => array(
 					'Some error message',
 					array(
 						'source'    => 'fatal-errors',
@@ -553,7 +553,39 @@ namespace Automattic\WooCommerce\Tests\Internal\Logging {
 					),
 					true,
 				),
-				'Non-fatal-errors source'   => array(
+				'Store API WooCommerce error message' => array(
+					'Store API request failed in Automattic\\WooCommerce\\StoreApi\\Routes\\V1\\Cart: TypeError: Some error message in ' . $wc_plugin_dir . 'src/Utilities/NumberUtil.php:54',
+					array(
+						'source'    => 'store-api',
+						'backtrace' => array( '#0 /plugins/some-other-plugin/file.php(24): Automattic\\WooCommerce\\Utilities\\NumberUtil::round(1.5, \'x\')' ),
+					),
+					false,
+				),
+				'Store API WooCommerce backtrace'     => array(
+					'Store API request failed in Automattic\\WooCommerce\\StoreApi\\Routes\\V1\\Cart: TypeError: Some error message in /plugins/some-other-plugin/file.php:10',
+					array(
+						'source'    => 'store-api',
+						'backtrace' => array(
+							'#0 ' . $wp_includes_dir . 'class-wp-hook.php(353): {closure}(Object(WC_Cart))',
+							'#1 ' . $wc_plugin_dir . 'includes/class-wc-cart.php(2269): do_action(\'woocommerce_cart_calculate_fees\')',
+							'#2 /plugins/some-other-plugin/file.php(10): WC_Cart->calculate_fees()',
+						),
+					),
+					false,
+				),
+				'Store API third-party backtrace'     => array(
+					'Store API request failed in Automattic\\WooCommerce\\StoreApi\\Routes\\V1\\Cart: TypeError: Some error message in /plugins/some-other-plugin/file.php:10',
+					array(
+						'source'    => 'store-api',
+						'backtrace' => array(
+							'/plugins/some-other-plugin/other-file.php',
+							$wp_includes_dir . 'class-wp-hook.php',
+							$wc_plugin_dir . 'includes/class-wc-cart.php',
+						),
+					),
+					true,
+				),
+				'Non-fatal-errors source'             => array(
 					'Some error message',
 					array(
 						'source'    => 'other-source',
@@ -561,7 +593,7 @@ namespace Automattic\WooCommerce\Tests\Internal\Logging {
 					),
 					false,
 				),
-				'Missing backtrace'         => array(
+				'Missing backtrace'                   => array(
 					'Some error message',
 					array( 'source' => 'fatal-errors' ),
 					true,