From 39b208ae80848a2a1a9c927652e67b98e1d320f8 Mon Sep 17 00:00:00 2001 From: Benedikt Franke Date: Tue, 7 Jul 2026 11:20:27 +0200 Subject: [PATCH 1/7] fix: Strip breadcrumb metadata on OOM to prevent secondary OOM during serialization MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When an out-of-memory error occurs with large breadcrumbs (e.g. SQL queries with bindings), serializing the event requires more working memory than the 5MB headroom provided by ErrorHandler. This causes a secondary OOM during json_encode in PayloadSerializer, which is silently caught and discards the entire event. Detect OOM in FatalErrorListenerIntegration and replace breadcrumbs with metadata-stripped copies before capture. This preserves the full breadcrumb trail (type, category, level, timestamp, message) while freeing the bulky data fields that cause the serialization to exceed available memory. Also adds Scope::getBreadcrumbs() to enable reading breadcrumbs for transformation without introducing an event processor that would run after the large breadcrumbs are already copied to the event. Fixes getsentry/sentry-laravel#909 🤖 Generated with Claude Code --- .../FatalErrorListenerIntegration.php | 25 +++++ src/State/Scope.php | 10 ++ ...ith_large_breadcrumbs_strips_metadata.phpt | 104 ++++++++++++++++++ 3 files changed, 139 insertions(+) create mode 100644 tests/phpt-oom/php84/out_of_memory_with_large_breadcrumbs_strips_metadata.phpt diff --git a/src/Integration/FatalErrorListenerIntegration.php b/src/Integration/FatalErrorListenerIntegration.php index 688103cd48..d8a0a8eacd 100644 --- a/src/Integration/FatalErrorListenerIntegration.php +++ b/src/Integration/FatalErrorListenerIntegration.php @@ -4,9 +4,11 @@ namespace Sentry\Integration; +use Sentry\Breadcrumb; use Sentry\ErrorHandler; use Sentry\Exception\FatalErrorException; use Sentry\SentrySdk; +use Sentry\State\Scope; /** * This integration hooks into the error handler and captures fatal errors. @@ -15,6 +17,8 @@ */ final class FatalErrorListenerIntegration extends AbstractErrorListenerIntegration { + private const OOM_MESSAGE_MATCHER = '/Allowed memory size of \d+ bytes exhausted/'; + /** * {@inheritdoc} */ @@ -36,6 +40,27 @@ public function setupOnce(): void return; } + if (preg_match(self::OOM_MESSAGE_MATCHER, $exception->getMessage()) === 1) { + $currentHub->configureScope(static function (Scope $scope): void { + $strippedBreadcrumbs = array_map(static function (Breadcrumb $breadcrumb): Breadcrumb { + return new Breadcrumb( + $breadcrumb->getLevel(), + $breadcrumb->getType(), + $breadcrumb->getCategory(), + $breadcrumb->getMessage(), + [], + $breadcrumb->getTimestamp() + ); + }, $scope->getBreadcrumbs()); + + $scope->clearBreadcrumbs(); + + foreach ($strippedBreadcrumbs as $breadcrumb) { + $scope->addBreadcrumb($breadcrumb); + } + }); + } + $integration->captureException($currentHub, $exception); }); } diff --git a/src/State/Scope.php b/src/State/Scope.php index ba7f4e3193..73186c4320 100644 --- a/src/State/Scope.php +++ b/src/State/Scope.php @@ -326,6 +326,16 @@ public function addBreadcrumb(Breadcrumb $breadcrumb, int $maxBreadcrumbs = 100) return $this; } + /** + * Gets the breadcrumbs. + * + * @return Breadcrumb[] + */ + public function getBreadcrumbs(): array + { + return $this->breadcrumbs; + } + /** * Clears all the breadcrumbs. * diff --git a/tests/phpt-oom/php84/out_of_memory_with_large_breadcrumbs_strips_metadata.phpt b/tests/phpt-oom/php84/out_of_memory_with_large_breadcrumbs_strips_metadata.phpt new file mode 100644 index 0000000000..913e774a3c --- /dev/null +++ b/tests/phpt-oom/php84/out_of_memory_with_large_breadcrumbs_strips_metadata.phpt @@ -0,0 +1,104 @@ +--TEST-- +Test that when handling an OOM error with large breadcrumbs, breadcrumb metadata is stripped to prevent secondary OOM during serialization +--SKIPIF-- += 80400) { + die('skip - only works for PHP 8.4 and below'); +} +--INI-- +memory_limit=67108864 +--FILE-- + 'http://public@example.com/sentry/1', +]); + +$transport = new class(new PayloadSerializer($options)) implements TransportInterface { + private $payloadSerializer; + + public function __construct(PayloadSerializerInterface $payloadSerializer) + { + $this->payloadSerializer = $payloadSerializer; + } + + public function send(Event $event): Result + { + $breadcrumbs = $event->getBreadcrumbs(); + echo 'Breadcrumb count: ' . \count($breadcrumbs) . \PHP_EOL; + + if (\count($breadcrumbs) > 0) { + $firstBreadcrumb = $breadcrumbs[0]; + echo 'First breadcrumb category: ' . $firstBreadcrumb->getCategory() . \PHP_EOL; + echo 'First breadcrumb has metadata: ' . (empty($firstBreadcrumb->getMetadata()) ? 'no' : 'yes') . \PHP_EOL; + } + + $serialized = $this->payloadSerializer->serialize($event); + + echo 'Transport called' . \PHP_EOL; + + return new Result(ResultStatus::success()); + } + + public function close(?int $timeout = null): Result + { + return new Result(ResultStatus::success()); + } +}; + +$options->setTransport($transport); + +$client = (new ClientBuilder($options))->getClient(); + +SentrySdk::init()->bindClient($client); + +// Add 100 breadcrumbs with ~100KB metadata each to simulate the real-world scenario +$hub = SentrySdk::getCurrentHub(); +$hub->configureScope(function (\Sentry\State\Scope $scope): void { + for ($i = 0; $i < 100; ++$i) { + $scope->addBreadcrumb(new Breadcrumb( + Breadcrumb::LEVEL_INFO, + Breadcrumb::TYPE_DEFAULT, + 'db.query', + 'SELECT * FROM large_table WHERE id = ?', + ['bindings' => str_repeat('x', 100 * 1024)] + )); + } +}); + +// Trigger OOM - the remaining memory after breadcrumbs is limited +$array = []; +for ($i = 0; $i < 100000000; ++$i) { + $array[] = 'sentry'; +} +--EXPECTF-- +Fatal error: Allowed memory size of %d bytes exhausted (tried to allocate %d bytes) in %s on line %d +Breadcrumb count: 100 +First breadcrumb category: db.query +First breadcrumb has metadata: no +Transport called From 05963e39a350296a45683be8ceae2425ed17c803 Mon Sep 17 00:00:00 2001 From: Benedikt Franke Date: Tue, 7 Jul 2026 11:53:44 +0200 Subject: [PATCH 2/7] fix: preserve all breadcrumbs regardless of configured max_breadcrumbs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The re-add loop after stripping metadata used addBreadcrumb's default cap of 100, silently truncating breadcrumbs when the user configured a higher limit. Pass the actual count so all stripped breadcrumbs survive. 🤖 Generated with Claude Code --- src/Integration/FatalErrorListenerIntegration.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Integration/FatalErrorListenerIntegration.php b/src/Integration/FatalErrorListenerIntegration.php index d8a0a8eacd..6b2f9e21ab 100644 --- a/src/Integration/FatalErrorListenerIntegration.php +++ b/src/Integration/FatalErrorListenerIntegration.php @@ -56,7 +56,7 @@ public function setupOnce(): void $scope->clearBreadcrumbs(); foreach ($strippedBreadcrumbs as $breadcrumb) { - $scope->addBreadcrumb($breadcrumb); + $scope->addBreadcrumb($breadcrumb, \count($strippedBreadcrumbs)); } }); } From e23e980ec6830eb33377640fe078982f8d9b476c Mon Sep 17 00:00:00 2001 From: Benedikt Franke Date: Tue, 7 Jul 2026 12:04:05 +0200 Subject: [PATCH 3/7] test: add php85 variant of OOM breadcrumb stripping test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PHP 8.5 appends a stack trace to the fatal error output, requiring a separate EXPECTF block. Also remove unused $serialized variable from the php84 variant. 🤖 Generated with Claude Code --- ...ith_large_breadcrumbs_strips_metadata.phpt | 2 +- ...ith_large_breadcrumbs_strips_metadata.phpt | 106 ++++++++++++++++++ 2 files changed, 107 insertions(+), 1 deletion(-) create mode 100644 tests/phpt-oom/php85/out_of_memory_with_large_breadcrumbs_strips_metadata.phpt diff --git a/tests/phpt-oom/php84/out_of_memory_with_large_breadcrumbs_strips_metadata.phpt b/tests/phpt-oom/php84/out_of_memory_with_large_breadcrumbs_strips_metadata.phpt index 913e774a3c..c8aa84952f 100644 --- a/tests/phpt-oom/php84/out_of_memory_with_large_breadcrumbs_strips_metadata.phpt +++ b/tests/phpt-oom/php84/out_of_memory_with_large_breadcrumbs_strips_metadata.phpt @@ -58,7 +58,7 @@ $transport = new class(new PayloadSerializer($options)) implements TransportInte echo 'First breadcrumb has metadata: ' . (empty($firstBreadcrumb->getMetadata()) ? 'no' : 'yes') . \PHP_EOL; } - $serialized = $this->payloadSerializer->serialize($event); + $this->payloadSerializer->serialize($event); echo 'Transport called' . \PHP_EOL; diff --git a/tests/phpt-oom/php85/out_of_memory_with_large_breadcrumbs_strips_metadata.phpt b/tests/phpt-oom/php85/out_of_memory_with_large_breadcrumbs_strips_metadata.phpt new file mode 100644 index 0000000000..3d72990d0f --- /dev/null +++ b/tests/phpt-oom/php85/out_of_memory_with_large_breadcrumbs_strips_metadata.phpt @@ -0,0 +1,106 @@ +--TEST-- +Test that when handling an OOM error with large breadcrumbs, breadcrumb metadata is stripped to prevent secondary OOM during serialization +--SKIPIF-- + 'http://public@example.com/sentry/1', +]); + +$transport = new class(new PayloadSerializer($options)) implements TransportInterface { + private $payloadSerializer; + + public function __construct(PayloadSerializerInterface $payloadSerializer) + { + $this->payloadSerializer = $payloadSerializer; + } + + public function send(Event $event): Result + { + $breadcrumbs = $event->getBreadcrumbs(); + echo 'Breadcrumb count: ' . \count($breadcrumbs) . \PHP_EOL; + + if (\count($breadcrumbs) > 0) { + $firstBreadcrumb = $breadcrumbs[0]; + echo 'First breadcrumb category: ' . $firstBreadcrumb->getCategory() . \PHP_EOL; + echo 'First breadcrumb has metadata: ' . (empty($firstBreadcrumb->getMetadata()) ? 'no' : 'yes') . \PHP_EOL; + } + + $this->payloadSerializer->serialize($event); + + echo 'Transport called' . \PHP_EOL; + + return new Result(ResultStatus::success()); + } + + public function close(?int $timeout = null): Result + { + return new Result(ResultStatus::success()); + } +}; + +$options->setTransport($transport); + +$client = (new ClientBuilder($options))->getClient(); + +SentrySdk::init()->bindClient($client); + +// Add 100 breadcrumbs with ~100KB metadata each to simulate the real-world scenario +$hub = SentrySdk::getCurrentHub(); +$hub->configureScope(function (\Sentry\State\Scope $scope): void { + for ($i = 0; $i < 100; ++$i) { + $scope->addBreadcrumb(new Breadcrumb( + Breadcrumb::LEVEL_INFO, + Breadcrumb::TYPE_DEFAULT, + 'db.query', + 'SELECT * FROM large_table WHERE id = ?', + ['bindings' => str_repeat('x', 100 * 1024)] + )); + } +}); + +// Trigger OOM - the remaining memory after breadcrumbs is limited +$array = []; +for ($i = 0; $i < 100000000; ++$i) { + $array[] = 'sentry'; +} +--EXPECTF-- +Fatal error: Allowed memory size of %d bytes exhausted (tried to allocate %d bytes) in %s on line %d +Stack trace: +%A +Breadcrumb count: 100 +First breadcrumb category: db.query +First breadcrumb has metadata: no +Transport called From 369864a92d736a84b17eeba845d9c3c7fa546f10 Mon Sep 17 00:00:00 2001 From: Benedikt Franke Date: Tue, 7 Jul 2026 12:11:50 +0200 Subject: [PATCH 4/7] docs: clarify why OOM_MESSAGE_MATCHER differs from ErrorHandler's MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The FatalErrorException message has an "Error: " prefix, so the anchor from ErrorHandler's pattern would not match. No captures are needed since this is a boolean check. 🤖 Generated with Claude Code --- src/Integration/FatalErrorListenerIntegration.php | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/Integration/FatalErrorListenerIntegration.php b/src/Integration/FatalErrorListenerIntegration.php index 6b2f9e21ab..45ded7efb4 100644 --- a/src/Integration/FatalErrorListenerIntegration.php +++ b/src/Integration/FatalErrorListenerIntegration.php @@ -17,6 +17,8 @@ */ final class FatalErrorListenerIntegration extends AbstractErrorListenerIntegration { + // Intentionally looser than ErrorHandler::OOM_MESSAGE_MATCHER — matches the + // prefixed FatalErrorException message and needs no capture groups. private const OOM_MESSAGE_MATCHER = '/Allowed memory size of \d+ bytes exhausted/'; /** From c30b256c166087e57bb2dda5bd8b740af57750e3 Mon Sep 17 00:00:00 2001 From: Benedikt Franke Date: Tue, 7 Jul 2026 12:15:10 +0200 Subject: [PATCH 5/7] style: use PHPDoc for OOM_MESSAGE_MATCHER explanation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit https://github.com/getsentry/sentry-php/pull/2150#discussion_r3535514299 🤖 Generated with Claude Code --- src/Integration/FatalErrorListenerIntegration.php | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/Integration/FatalErrorListenerIntegration.php b/src/Integration/FatalErrorListenerIntegration.php index 45ded7efb4..55d7e8abf3 100644 --- a/src/Integration/FatalErrorListenerIntegration.php +++ b/src/Integration/FatalErrorListenerIntegration.php @@ -17,8 +17,10 @@ */ final class FatalErrorListenerIntegration extends AbstractErrorListenerIntegration { - // Intentionally looser than ErrorHandler::OOM_MESSAGE_MATCHER — matches the - // prefixed FatalErrorException message and needs no capture groups. + /** + * Intentionally looser than ErrorHandler::OOM_MESSAGE_MATCHER — matches the + * prefixed FatalErrorException message and needs no capture groups. + */ private const OOM_MESSAGE_MATCHER = '/Allowed memory size of \d+ bytes exhausted/'; /** From 395aa522aeb2bdd61a96b732bbbb8e96b679c0c3 Mon Sep 17 00:00:00 2001 From: Benedikt Franke Date: Tue, 7 Jul 2026 12:23:23 +0200 Subject: [PATCH 6/7] fix: correct SKIPIF boundary in OOM tests to include PHP 8.4 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The php84/ OOM tests used >= 80400, excluding PHP 8.4 itself. The stack trace output change only happened in 8.5, so these tests should run on 8.4 as well — matching the tests/phpt/php84/ convention of >= 80500. https://github.com/getsentry/sentry-php/pull/2150#discussion_r3535540362 🤖 Generated with Claude Code --- .../php84/out_of_memory_fatal_error_increases_memory_limit.phpt | 2 +- .../out_of_memory_with_large_breadcrumbs_strips_metadata.phpt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/phpt-oom/php84/out_of_memory_fatal_error_increases_memory_limit.phpt b/tests/phpt-oom/php84/out_of_memory_fatal_error_increases_memory_limit.phpt index 35a32c8381..bbd1429319 100644 --- a/tests/phpt-oom/php84/out_of_memory_fatal_error_increases_memory_limit.phpt +++ b/tests/phpt-oom/php84/out_of_memory_fatal_error_increases_memory_limit.phpt @@ -2,7 +2,7 @@ Test that when handling a out of memory error the memory limit is increased with 5 MiB and the event is serialized and ready to be sent --SKIPIF-- = 80400) { +if (PHP_VERSION_ID >= 80500) { die('skip - only works for PHP 8.4 and below'); } --INI-- diff --git a/tests/phpt-oom/php84/out_of_memory_with_large_breadcrumbs_strips_metadata.phpt b/tests/phpt-oom/php84/out_of_memory_with_large_breadcrumbs_strips_metadata.phpt index c8aa84952f..bdf0d21c4c 100644 --- a/tests/phpt-oom/php84/out_of_memory_with_large_breadcrumbs_strips_metadata.phpt +++ b/tests/phpt-oom/php84/out_of_memory_with_large_breadcrumbs_strips_metadata.phpt @@ -2,7 +2,7 @@ Test that when handling an OOM error with large breadcrumbs, breadcrumb metadata is stripped to prevent secondary OOM during serialization --SKIPIF-- = 80400) { +if (PHP_VERSION_ID >= 80500) { die('skip - only works for PHP 8.4 and below'); } --INI-- From 98b9e28eefd0837d728766cba56f22dae303cbe7 Mon Sep 17 00:00:00 2001 From: Benedikt Franke Date: Tue, 7 Jul 2026 15:17:49 +0200 Subject: [PATCH 7/7] fix: suppress deprecation notices before autoloader in OOM tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Moves error_reporting() before require autoload.php so that OpenTelemetry's implicit-nullable deprecation notices on PHP 8.4/8.5 lowest deps don't pollute the PHPT expected output. 🤖 Generated with Claude Code --- .../out_of_memory_fatal_error_increases_memory_limit.phpt | 4 ++-- .../out_of_memory_with_large_breadcrumbs_strips_metadata.phpt | 4 ++-- .../out_of_memory_with_large_breadcrumbs_strips_metadata.phpt | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/tests/phpt-oom/php84/out_of_memory_fatal_error_increases_memory_limit.phpt b/tests/phpt-oom/php84/out_of_memory_fatal_error_increases_memory_limit.phpt index bbd1429319..5a031e9f8a 100644 --- a/tests/phpt-oom/php84/out_of_memory_fatal_error_increases_memory_limit.phpt +++ b/tests/phpt-oom/php84/out_of_memory_fatal_error_increases_memory_limit.phpt @@ -24,6 +24,8 @@ use Sentry\Transport\Result; use Sentry\Transport\ResultStatus; use Sentry\Transport\TransportInterface; +error_reporting(E_ALL & ~E_DEPRECATED & ~E_USER_DEPRECATED); + $vendor = __DIR__; while (!file_exists($vendor . '/vendor')) { @@ -32,8 +34,6 @@ while (!file_exists($vendor . '/vendor')) { require $vendor . '/vendor/autoload.php'; -error_reporting(E_ALL & ~E_DEPRECATED & ~E_USER_DEPRECATED); - $options = new Options([ 'dsn' => 'http://public@example.com/sentry/1', ]); diff --git a/tests/phpt-oom/php84/out_of_memory_with_large_breadcrumbs_strips_metadata.phpt b/tests/phpt-oom/php84/out_of_memory_with_large_breadcrumbs_strips_metadata.phpt index bdf0d21c4c..9a8a5cf869 100644 --- a/tests/phpt-oom/php84/out_of_memory_with_large_breadcrumbs_strips_metadata.phpt +++ b/tests/phpt-oom/php84/out_of_memory_with_large_breadcrumbs_strips_metadata.phpt @@ -25,6 +25,8 @@ use Sentry\Transport\Result; use Sentry\Transport\ResultStatus; use Sentry\Transport\TransportInterface; +error_reporting(E_ALL & ~E_DEPRECATED & ~E_USER_DEPRECATED); + $vendor = __DIR__; while (!file_exists($vendor . '/vendor')) { @@ -33,8 +35,6 @@ while (!file_exists($vendor . '/vendor')) { require $vendor . '/vendor/autoload.php'; -error_reporting(E_ALL & ~E_DEPRECATED & ~E_USER_DEPRECATED); - $options = new Options([ 'dsn' => 'http://public@example.com/sentry/1', ]); diff --git a/tests/phpt-oom/php85/out_of_memory_with_large_breadcrumbs_strips_metadata.phpt b/tests/phpt-oom/php85/out_of_memory_with_large_breadcrumbs_strips_metadata.phpt index 3d72990d0f..606222acb4 100644 --- a/tests/phpt-oom/php85/out_of_memory_with_large_breadcrumbs_strips_metadata.phpt +++ b/tests/phpt-oom/php85/out_of_memory_with_large_breadcrumbs_strips_metadata.phpt @@ -25,6 +25,8 @@ use Sentry\Transport\Result; use Sentry\Transport\ResultStatus; use Sentry\Transport\TransportInterface; +error_reporting(E_ALL & ~E_DEPRECATED & ~E_USER_DEPRECATED); + $vendor = __DIR__; while (!file_exists($vendor . '/vendor')) { @@ -33,8 +35,6 @@ while (!file_exists($vendor . '/vendor')) { require $vendor . '/vendor/autoload.php'; -error_reporting(E_ALL & ~E_DEPRECATED & ~E_USER_DEPRECATED); - $options = new Options([ 'dsn' => 'http://public@example.com/sentry/1', ]);