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,