From 8efa57b6221cbf40d69c8e771fceea25763204e9 Mon Sep 17 00:00:00 2001 From: Pascal CESCON - Amoifr Date: Mon, 19 Oct 2026 14:53:34 +0200 Subject: [PATCH] feat(serializer): Add a max_breadth option to limit the items serialized from arrays and objects --- src/Options.php | 35 ++++++++++++ src/Serializer/AbstractSerializer.php | 34 ++++++++++++ src/functions.php | 1 + tests/OptionsTest.php | 27 ++++++++++ tests/Serializer/AbstractSerializerTest.php | 54 ++++++++++++++++++- .../RepresentationSerializerTest.php | 4 +- 6 files changed, 152 insertions(+), 3 deletions(-) diff --git a/src/Options.php b/src/Options.php index c6d4b002f..f849df860 100644 --- a/src/Options.php +++ b/src/Options.php @@ -1014,6 +1014,28 @@ public function setMaxValueLength(int $maxValueLength): self return $this->updateOptions(['max_value_length' => $maxValueLength]); } + /** + * Gets the maximum number of items serialized from an array or an object, + * or null if there is no limit. + */ + public function getMaxBreadth(): ?int + { + $maxBreadth = $this->options['max_breadth']; + + return \is_int($maxBreadth) ? $maxBreadth : null; + } + + /** + * Sets the maximum number of items serialized from an array or an object, + * such as the arguments of the stack frames. + * + * @param int|null $maxBreadth The number of items after which the next ones are left out, or null for no limit + */ + public function setMaxBreadth(?int $maxBreadth): self + { + return $this->updateOptions(['max_breadth' => $maxBreadth]); + } + /** * Gets the http proxy setting. */ @@ -1292,6 +1314,7 @@ private function configureOptions(OptionsResolver $resolver): void $resolver->setAllowedTypes('send_default_pii', 'bool'); $resolver->setAllowedTypes('default_integrations', 'bool'); $resolver->setAllowedTypes('max_value_length', 'int'); + $resolver->setAllowedTypes('max_breadth', ['null', 'int']); $resolver->setAllowedTypes('transport', ['null', TransportInterface::class]); $resolver->setAllowedTypes('http_client', ['null', HttpClientInterface::class]); $resolver->setAllowedTypes('http_proxy', ['null', 'string']); @@ -1312,6 +1335,7 @@ private function configureOptions(OptionsResolver $resolver): void $resolver->setAllowedValues('max_breadcrumbs', \Closure::fromCallable([$this, 'validateMaxBreadcrumbsOptions'])); $resolver->setAllowedValues('class_serializers', \Closure::fromCallable([$this, 'validateClassSerializersOption'])); $resolver->setAllowedValues('context_lines', \Closure::fromCallable([$this, 'validateContextLinesOption'])); + $resolver->setAllowedValues('max_breadth', \Closure::fromCallable([$this, 'validateMaxBreadthOption'])); $resolver->setAllowedValues('log_flush_threshold', \Closure::fromCallable([$this, 'validateLogFlushThresholdOption'])); $resolver->setAllowedValues('metric_flush_threshold', \Closure::fromCallable([$this, 'validateMetricFlushThresholdOption'])); @@ -1401,6 +1425,7 @@ private function configureOptions(OptionsResolver $resolver): void 'in_app_include' => [], 'send_default_pii' => false, 'max_value_length' => 1024, + 'max_breadth' => null, 'transport' => null, 'http_client' => null, 'http_proxy' => null, @@ -1578,6 +1603,16 @@ private function validateContextLinesOption(?int $contextLines): bool return $contextLines === null || $contextLines >= 0; } + /** + * Validates that the value passed to the "max_breadth" option is valid. + * + * @param int|null $maxBreadth The value to validate + */ + private function validateMaxBreadthOption(?int $maxBreadth): bool + { + return $maxBreadth === null || $maxBreadth > 0; + } + /** * Validates that the value passed to the "log_flush_threshold" option is valid. * diff --git a/src/Serializer/AbstractSerializer.php b/src/Serializer/AbstractSerializer.php index df45ce817..a3121f2f3 100644 --- a/src/Serializer/AbstractSerializer.php +++ b/src/Serializer/AbstractSerializer.php @@ -43,6 +43,12 @@ abstract class AbstractSerializer */ public const WESTERN_MB_DETECT_ORDER = 'UTF-8, ASCII, ISO-8859-1, ISO-8859-2, ISO-8859-3, ISO-8859-4, ISO-8859-5, ISO-8859-6, ISO-8859-7, ISO-8859-8, ISO-8859-9, ISO-8859-10, ISO-8859-13, ISO-8859-14, ISO-8859-15, ISO-8859-16, Windows-1251, Windows-1252, Windows-1254'; + /** + * The key under which the number of items left out by the `max_breadth` + * option is reported. + */ + private const TRUNCATED_KEY = '{truncated}'; + /** * The maximum depth to reach when serializing recursively. * @@ -108,8 +114,15 @@ protected function serializeRecursively($value, int $_depth = 0) if (\is_array($value)) { $serializedArray = []; + $maxBreadth = $this->options->getMaxBreadth(); foreach ($value as $k => $v) { + if ($maxBreadth !== null && \count($serializedArray) >= $maxBreadth) { + $serializedArray[self::TRUNCATED_KEY] = $this->formatTruncatedItems(\count($value) - $maxBreadth); + + break; + } + $serializedArray[$k] = $this->serializeRecursively($v, $_depth + 1); } @@ -209,8 +222,17 @@ protected function serializeObject($object, int $_depth = 0, array $hashes = []) $hashes[] = spl_object_hash($object); $serializedObject = []; + $maxBreadth = $this->options->getMaxBreadth(); + $truncatedItems = 0; foreach ($object as $key => &$value) { + if ($maxBreadth !== null && \count($serializedObject) >= $maxBreadth) { + // the properties of an object cannot be counted without walking them, so only count the next ones + ++$truncatedItems; + + continue; + } + if (\is_object($value)) { $serializedObject[$key] = $this->serializeObject($value, $_depth + 1, $hashes); } else { @@ -218,9 +240,21 @@ protected function serializeObject($object, int $_depth = 0, array $hashes = []) } } + if ($truncatedItems > 0) { + $serializedObject[self::TRUNCATED_KEY] = $this->formatTruncatedItems($truncatedItems); + } + return $serializedObject; } + /** + * Describes the number of items left out because of the `max_breadth` option. + */ + private function formatTruncatedItems(int $count): string + { + return \sprintf('%d more item%s', $count, $count === 1 ? '' : 's'); + } + /** * Serializes the given value to a string. * diff --git a/src/functions.php b/src/functions.php index 432f00f57..08324457d 100644 --- a/src/functions.php +++ b/src/functions.php @@ -71,6 +71,7 @@ * log_flush_threshold?: int|null, * metric_flush_threshold?: int|null, * max_breadcrumbs?: int, + * max_breadth?: int|null, * max_request_body_size?: "none"|"never"|"small"|"medium"|"always", * max_value_length?: int, * org_id?: int|null, diff --git a/tests/OptionsTest.php b/tests/OptionsTest.php index 050285612..54c6461b6 100644 --- a/tests/OptionsTest.php +++ b/tests/OptionsTest.php @@ -409,6 +409,13 @@ static function (array $integrations): array { 'setMaxValueLength', ]; + yield [ + 'max_breadth', + 10, + 'getMaxBreadth', + 'setMaxBreadth', + ]; + yield [ 'transport', new HttpTransport(new Options(), new HttpClient('foo', 'bar'), new PayloadSerializer(new Options())), @@ -637,6 +644,7 @@ public function testDefaultOptionValues(): void 'send_default_pii' => false, 'data_collection' => null, 'max_value_length' => 1024, + 'max_breadth' => null, 'transport' => null, 'http_client' => null, 'http_proxy' => null, @@ -873,6 +881,25 @@ public static function maxBreadcrumbsOptionIsValidatedCorrectlyDataProvider(): a ]; } + /** + * @dataProvider maxBreadthOptionValidatesInputValueDataProvider + */ + public function testMaxBreadthOptionValidatesInputValue(?int $value, ?int $expectedValue): void + { + $options = new Options(['max_breadth' => $value]); + + $this->assertSame($expectedValue, $options->getMaxBreadth()); + } + + public static function maxBreadthOptionValidatesInputValueDataProvider(): \Generator + { + yield [null, null]; + yield [1, 1]; + yield [10, 10]; + yield [0, null]; + yield [-1, null]; + } + /** * @dataProvider contextLinesOptionValidatesInputValueDataProvider */ diff --git a/tests/Serializer/AbstractSerializerTest.php b/tests/Serializer/AbstractSerializerTest.php index f2e447a70..10390a92b 100644 --- a/tests/Serializer/AbstractSerializerTest.php +++ b/tests/Serializer/AbstractSerializerTest.php @@ -5,13 +5,14 @@ namespace Sentry\Tests\Serializer; use PHPUnit\Framework\TestCase; +use Sentry\Options; use Sentry\Serializer\AbstractSerializer; use Sentry\Serializer\RepresentationSerializerInterface; use Sentry\Serializer\SerializerInterface; abstract class AbstractSerializerTest extends TestCase { - abstract protected function createSerializer(): AbstractSerializer; + abstract protected function createSerializer(?Options $options = null): AbstractSerializer; /** * This method is only existed because of testSerializeCallable. @@ -463,6 +464,57 @@ public function testLongString(bool $serializeAllObjects): void } } + public function testArrayItemsAreNotLimitedByDefault(): void + { + $input = array_fill(0, 100, 'foo'); + + $this->assertSame($input, $this->invokeSerialization($this->createSerializer(), $input)); + } + + /** + * @dataProvider maxBreadthLimitsTheItemsOfAnArrayDataProvider + */ + public function testMaxBreadthLimitsTheItemsOfAnArray(int $maxBreadth, array $input, array $expectedResult): void + { + $serializer = $this->createSerializer(new Options(['max_breadth' => $maxBreadth])); + + $this->assertSame($expectedResult, $this->invokeSerialization($serializer, $input)); + } + + public static function maxBreadthLimitsTheItemsOfAnArrayDataProvider(): \Generator + { + yield 'list longer than the limit' => [2, ['a', 'b', 'c', 'd', 'e'], ['a', 'b', '{truncated}' => '3 more items']]; + yield 'map longer than the limit' => [2, ['a' => 'foo', 'b' => 'bar', 'c' => 'baz'], ['a' => 'foo', 'b' => 'bar', '{truncated}' => '1 more item']]; + yield 'limit of one' => [1, ['a', 'b'], ['a', '{truncated}' => '1 more item']]; + yield 'as many items as the limit' => [2, ['a', 'b'], ['a', 'b']]; + yield 'fewer items than the limit' => [2, ['a'], ['a']]; + yield 'empty array' => [2, [], []]; + } + + public function testMaxBreadthLimitsTheItemsOfNestedArrays(): void + { + $serializer = $this->createSerializer(new Options(['max_breadth' => 2])); + $input = [['a', 'b', 'c'], ['d', 'e', 'f'], ['g', 'h', 'i']]; + + $this->assertSame([['a', 'b', '{truncated}' => '1 more item'], ['d', 'e', '{truncated}' => '1 more item'], '{truncated}' => '1 more item'], $this->invokeSerialization($serializer, $input)); + } + + /** + * @dataProvider serializeAllObjectsDataProvider + */ + public function testMaxBreadthLimitsThePropertiesOfAnObject(bool $serializeAllObjects): void + { + $serializer = $this->createSerializer(new Options(['max_breadth' => 2])); + + if ($serializeAllObjects) { + $serializer->setSerializeAllObjects(true); + } + + $input = (object) ['a' => 'foo', 'b' => 'bar', 'c' => 'baz', 'd' => 'qux']; + + $this->assertSame(['a' => 'foo', 'b' => 'bar', '{truncated}' => '2 more items'], $this->invokeSerialization($serializer, $input)); + } + /** * @dataProvider serializeAllObjectsDataProvider */ diff --git a/tests/Serializer/RepresentationSerializerTest.php b/tests/Serializer/RepresentationSerializerTest.php index c3d9e7b2e..619f98adf 100644 --- a/tests/Serializer/RepresentationSerializerTest.php +++ b/tests/Serializer/RepresentationSerializerTest.php @@ -159,8 +159,8 @@ public function testSerializeFloatOutsideOfIntegerRange(bool $serializeAllObject /** * @return RepresentationSerializer */ - protected function createSerializer(): AbstractSerializer + protected function createSerializer(?Options $options = null): AbstractSerializer { - return new RepresentationSerializer(new Options()); + return new RepresentationSerializer($options ?? new Options()); } }