From 47ffb27b044c426965bfccea52b43d248198d8be Mon Sep 17 00:00:00 2001 From: Pavel Zotikov Date: Thu, 23 Apr 2026 17:08:53 +0300 Subject: [PATCH 1/6] fix: cap payload depth and improve serialized arg readability for Hawk Harden stack-trace and event payload building when frames include deeply nested or self-referential data (e.g. $GLOBALS): cap recursion depth in Serializer::prepare and EventPayloadBuilder::transformForJson, limit arguments per frame and per-line size, and keep only safe frame keys. Improve readability of serialized argument values for the Hawk UI by using JSON_PRETTY_PRINT and inserting zero-width break opportunities in long string scalars (valid JSON, smaller horizontal overflow). Tests updated to compare structured JSON and to cover long-string soft breaks. --- src/EventPayloadBuilder.php | 68 +++++++++++++++++-- src/Serializer.php | 54 +++++++++++++-- src/StacktraceFrameBuilder.php | 44 +++++++++++- tests/Unit/EventPayloadBuilderTest.php | 94 ++++++++++++++++++++++++++ tests/Unit/SerializerTest.php | 35 ++++++++-- 5 files changed, 274 insertions(+), 21 deletions(-) diff --git a/src/EventPayloadBuilder.php b/src/EventPayloadBuilder.php index 99b6269..89806c3 100644 --- a/src/EventPayloadBuilder.php +++ b/src/EventPayloadBuilder.php @@ -40,6 +40,11 @@ class EventPayloadBuilder 'additionalData', ]; + /** + * {@see transformForJson} max nesting — protects against $GLOBALS self-reference and similar cycles. + */ + private const TRANSFORM_JSON_MAX_DEPTH = 32; + /** * EventPayloadFactory constructor. */ @@ -158,15 +163,21 @@ private function normalizeBacktrace(array $stack): array $functionName = (string) $frame['functionName']; } + $arguments = $this->buildArgumentsList($frame); + $additional = []; foreach ($frame as $key => $value) { if (!in_array($key, self::ALLOWED_KEYS, true)) { + // Mapped to `arguments` via StacktraceFrameBuilder / string list; do not dump raw `args` here + if ($key === 'args') { + continue; + } // Drop heavy/unserializable objects from 'object' field; store class name instead if ($key === 'object') { $value = is_object($value) ? get_class($value) : $value; } - $additional[$key] = $this->transformForJson($value); + $additional[$key] = $this->transformForJson($value, 0); } } @@ -176,9 +187,7 @@ private function normalizeBacktrace(array $stack): array 'column' => null, 'sourceCode' => isset($frame['sourceCode']) && is_array($frame['sourceCode']) ? $frame['sourceCode'] : null, 'function' => $functionName, - // Keep arguments only if it already looks like desired string[]; otherwise omit - // Limit argument processing to first 10 items to avoid performance issues - 'arguments' => (isset($frame['arguments']) && is_array($frame['arguments'])) ? array_values(array_map('strval', array_slice($frame['arguments'], 0, 10))) : [], + 'arguments' => $arguments, 'additionalData'=> $additional, ]); } @@ -222,19 +231,66 @@ private function sanitizeArrayKeys($value) return $sanitized; } + /** + * Build Hawk `arguments` (string[]) from a frame: prefers ready `arguments`, else formats raw `args`. + * + * @param array $frame + * + * @return array + */ + private function buildArgumentsList(array $frame): array + { + $max = StacktraceFrameBuilder::MAX_FRAME_ARGUMENTS; + $maxBytes = StacktraceFrameBuilder::MAX_ARGUMENT_LINE_BYTES; + + if (isset($frame['arguments']) && is_array($frame['arguments'])) { + $out = []; + foreach (array_slice($frame['arguments'], 0, $max) as $line) { + $out[] = $this->truncateArgumentLineString((string) $line, $maxBytes); + } + + return $out; + } + + if (!empty($frame['args']) && is_array($frame['args'])) { + $out = []; + foreach (array_slice($this->stacktraceFrameBuilder->getFormattedArguments($frame), 0, $max) as $line) { + $out[] = $this->truncateArgumentLineString((string) $line, $maxBytes); + } + + return $out; + } + + return []; + } + + private function truncateArgumentLineString(string $line, int $maxBytes): string + { + if (strlen($line) <= $maxBytes) { + return $line; + } + + return substr($line, 0, $maxBytes - 3) . '...'; + } + /** * Transform values to JSON-serializable representation * * @param mixed $value + * @param int $depth * * @return mixed */ - private function transformForJson($value) + private function transformForJson($value, int $depth = 0) { + if ($depth > self::TRANSFORM_JSON_MAX_DEPTH) { + return '[max depth]'; + } + if (is_array($value)) { $result = []; foreach ($value as $k => $v) { - $result[$k] = $this->transformForJson($v); + $result[$k] = $this->transformForJson($v, $depth + 1); } return $result; diff --git a/src/Serializer.php b/src/Serializer.php index de642f9..d2014de 100644 --- a/src/Serializer.php +++ b/src/Serializer.php @@ -11,6 +11,12 @@ */ final class Serializer { + /** + * Long scalar strings: insert U+200B every N chars so UIs can wrap (like soft word-break), + * without breaking JSON validity. Does not alter tokens like short keys. + */ + private const SOFT_BREAK_EVERY_CHARS = 72; + /** * Process any value and makes it safe (in appropriate format) to send to hawk * @@ -20,7 +26,8 @@ final class Serializer */ public function serializeValue($value): string { - $encoded = json_encode($this->prepare($value), JSON_UNESCAPED_UNICODE); + $flags = JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT; + $encoded = json_encode($this->prepare($value, 0), $flags); if ($encoded === false) { return ''; @@ -29,29 +36,39 @@ public function serializeValue($value): string return $encoded; } + /** + * Max nesting depth to avoid runaway recursion on $GLOBALS and similar circular structures. + */ + private const PREPARE_MAX_DEPTH = 32; + /** * Prepares value for encoding * - * @param $value + * @param mixed $value + * @param int $depth * * @return array|mixed|string */ - private function prepare($value) + private function prepare($value, int $depth = 0) { + if ($depth > self::PREPARE_MAX_DEPTH) { + return '[max depth]'; + } + if (!is_object($value) && (is_array($value) || is_iterable($value))) { $result = []; foreach ($value as $key => $subValue) { if (is_array($subValue) || is_iterable($subValue)) { - $result[$key] = $this->prepare($subValue); + $result[$key] = $this->prepare($subValue, $depth + 1); } else { $result[$key] = $this->transform($subValue); } } return $result; - } else { - return $this->transform($value); } + + return $this->transform($value); } /** @@ -71,11 +88,36 @@ private function transform($value) return get_class($value); } elseif (is_resource($value)) { return 'Resource'; + } elseif (is_string($value)) { + return $this->insertSoftBreaksInString($value); } else { return $value; } } + /** + * Insert zero-width spaces for long strings so Hawk (or any monospace view) can wrap + * without CSS word-break; JSON remains valid after json_encode. + */ + private function insertSoftBreaksInString(string $value): string + { + $len = strlen($value); + if ($len <= self::SOFT_BREAK_EVERY_CHARS) { + return $value; + } + + $chunk = self::SOFT_BREAK_EVERY_CHARS; + $zwsp = "\u{200B}"; + + if (function_exists('mb_str_split')) { + $parts = mb_str_split($value, $chunk, 'UTF-8'); + + return implode($zwsp, $parts); + } + + return implode($zwsp, str_split($value, $chunk)); + } + /** * Check array if it is associative * diff --git a/src/StacktraceFrameBuilder.php b/src/StacktraceFrameBuilder.php index c2053d5..391b63a 100644 --- a/src/StacktraceFrameBuilder.php +++ b/src/StacktraceFrameBuilder.php @@ -14,6 +14,16 @@ */ final class StacktraceFrameBuilder { + /** + * Max function arguments to include per frame (payload size, CPU, Hawk limits). + */ + public const MAX_FRAME_ARGUMENTS = 5; + + /** + * Max length of one serialized "name = value" line (bytes; avoids huge JSON in events). + */ + public const MAX_ARGUMENT_LINE_BYTES = 2048; + /** * @var Serializer */ @@ -183,6 +193,19 @@ private function composeFunctionName(array $frame): string return $functionName; } + /** + * Format `args` from a raw debug_backtrace() frame to Hawk `arguments` (list of "name = value" strings). + * Public so {@see EventPayloadBuilder} can map `args` without duplicating logic. + * + * @param array $frame + * + * @return array + */ + public function getFormattedArguments(array $frame): array + { + return $this->getArgs($frame); + } + /** * Get function arguments for a frame * @@ -216,6 +239,9 @@ private function getArgs(array $frame): array */ if (!$reflection) { foreach ($frame['args'] as $index => $value) { + if ($index >= self::MAX_FRAME_ARGUMENTS) { + break; + } $arguments['arg' . $index] = $value; } } else { @@ -231,6 +257,10 @@ private function getArgs(array $frame): array $paramName = $reflectionParam->getName(); $paramPosition = $reflectionParam->getPosition(); + if ($paramPosition >= self::MAX_FRAME_ARGUMENTS) { + break; + } + if (isset($frame['args'][$paramPosition])) { $arguments[$paramName] = $frame['args'][$paramPosition]; } @@ -246,15 +276,23 @@ private function getArgs(array $frame): array $value = $this->serializer->serializeValue($value); try { - $newArguments[] = sprintf('%s = %s', $name, $value); + $line = sprintf('%s = %s', $name, $value); + $newArguments[] = $this->truncateArgumentLine($line); } catch (\Exception $e) { // Ignore unknown types } } - $arguments = $newArguments; + return $newArguments; + } + + private function truncateArgumentLine(string $line): string + { + if (strlen($line) <= self::MAX_ARGUMENT_LINE_BYTES) { + return $line; + } - return $arguments; + return substr($line, 0, self::MAX_ARGUMENT_LINE_BYTES - 3) . '...'; } /** diff --git a/tests/Unit/EventPayloadBuilderTest.php b/tests/Unit/EventPayloadBuilderTest.php index 02129d2..d01440d 100644 --- a/tests/Unit/EventPayloadBuilderTest.php +++ b/tests/Unit/EventPayloadBuilderTest.php @@ -13,6 +13,100 @@ class EventPayloadBuilderTest extends TestCase { + /** + * @return array{0: EventPayloadBuilder, 1: \ReflectionMethod} + */ + private function builderWithNormalizeBacktrace(): array + { + $serializer = new Serializer(); + $stack = new StacktraceFrameBuilder($serializer); + $builder = new EventPayloadBuilder($stack); + $m = new \ReflectionMethod(EventPayloadBuilder::class, 'normalizeBacktrace'); + $m->setAccessible(true); + + return [$builder, $m]; + } + + public function testNormalizeBacktraceDoesNotPutRawArgsIntoAdditionalData(): void + { + [$builder, $normalize] = $this->builderWithNormalizeBacktrace(); + + $frame = [ + 'file' => '/fake/handler.php', + 'line' => 7, + 'class' => 'SomeErrorHandler', + 'type' => '::', + 'function' => 'handle', + 'args' => [256, 'message', __FILE__, 7, ['nested' => true]], + ]; + + $stack = $normalize->invoke($builder, [$frame]); + + $this->assertArrayNotHasKey('args', $stack[0]['additionalData'] ?? []); + $this->assertNotEmpty($stack[0]['arguments'] ?? []); + } + + public function testNormalizeBacktraceLimitsArgumentCount(): void + { + [$builder, $normalize] = $this->builderWithNormalizeBacktrace(); + + $frame = [ + 'file' => '/x.php', + 'line' => 1, + 'function' => 'not_registered_function_'.uniqid(), + 'args' => [1, 2, 3, 4, 5, 6, 7, 8, 9, 10], + ]; + + $stack = $normalize->invoke($builder, [$frame]); + + $this->assertCount(StacktraceFrameBuilder::MAX_FRAME_ARGUMENTS, $stack[0]['arguments']); + } + + public function testNormalizeBacktraceTruncatesPrebuiltArgumentLines(): void + { + [$builder, $normalize] = $this->builderWithNormalizeBacktrace(); + $long = str_repeat('Z', StacktraceFrameBuilder::MAX_ARGUMENT_LINE_BYTES + 100); + + $frame = [ + 'file' => '/x.php', + 'line' => 1, + 'function' => 'f', + 'arguments' => [$long], + ]; + + $stack = $normalize->invoke($builder, [$frame]); + $line = $stack[0]['arguments'][0] ?? ''; + + $this->assertLessThanOrEqual(StacktraceFrameBuilder::MAX_ARGUMENT_LINE_BYTES, strlen($line)); + $this->assertStringEndsWith('...', $line); + } + + public function testNormalizeBacktraceFinishesWithGlobalsLikeNestingInAdditionalData(): void + { + [$builder, $normalize] = $this->builderWithNormalizeBacktrace(); + + $deep = ['level' => []]; + $cur =& $deep['level']; + for ($i = 0; $i < 50; $i++) { + $cur['d'] = []; + $cur =& $cur['d']; + } + $cur['leaf'] = 1; + + $frame = [ + 'file' => '/x.php', + 'line' => 1, + 'function' => 'x', + 'class' => 'C', + 'type' => '->', + 'args' => [1], + 'custom' => $deep, + ]; + + $stack = $normalize->invoke($builder, [$frame]); + $this->assertIsArray($stack[0]['additionalData']['custom'] ?? null); + } + public function testCreationWithDefaultException(): void { $context = [ diff --git a/tests/Unit/SerializerTest.php b/tests/Unit/SerializerTest.php index e5d4691..89ac9f8 100644 --- a/tests/Unit/SerializerTest.php +++ b/tests/Unit/SerializerTest.php @@ -19,7 +19,17 @@ public function testSerializationResult($testCase) $fixture = new Serializer(); $result = $fixture->serializeValue($testCase['value']); - $this->assertEquals($testCase['expect'], $result); + if ($testCase['expect'] === '') { + $this->assertSame('', $result); + + return; + } + + // Output uses JSON_PRETTY_PRINT; compare decoded structure (and scalars) for stability + $this->assertEquals( + json_decode($testCase['expect'], true, 512, JSON_THROW_ON_ERROR), + json_decode($result, true, 512, JSON_THROW_ON_ERROR) + ); } public function testSerializationWithMediumSizeArray(): void @@ -30,21 +40,34 @@ public function testSerializationWithMediumSizeArray(): void $fixture = new Serializer(); $result = $fixture->serializeValue($mediumArray); - $this->assertEquals('{"1":{"2":{"3":{"4":{"5":{"6":{"7":{"8":{"9":{"10":{"11":{"12":{"13":{"14":{"15":{"16":{"17":{"18":{"19":[]}}}}}}}}}}}}}}}}}}}', $result); + $this->assertEquals( + json_decode('{"1":{"2":{"3":{"4":{"5":{"6":{"7":{"8":{"9":{"10":{"11":{"12":{"13":{"14":{"15":{"16":{"17":{"18":{"19":[]}}}}}}}}}}}}}}}}}}}', true, 512, JSON_THROW_ON_ERROR), + json_decode($result, true, 512, JSON_THROW_ON_ERROR) + ); } public function testSerializationWithLargeArray(): void { - // MaxDepth is 1000 $largeArray = []; $this->fillArray($largeArray); $fixture = new Serializer(); - - // json_encode will return false and result is empty string $result = $fixture->serializeValue($largeArray); - $this->assertEquals('', trim($result)); + // Very deep trees are truncated instead of making json_encode fail + $this->assertStringContainsString('[max depth]', $result); + $this->assertIsArray(json_decode($result, true)); + } + + public function testLongStringValuesGetSoftBreakPointsForDisplay(): void + { + $long = str_repeat('a', 200); + $fixture = new Serializer(); + $result = $fixture->serializeValue($long); + $this->assertStringContainsString("\u{200B}", $result); + $decoded = json_decode($result, false, 512, JSON_THROW_ON_ERROR); + $this->assertIsString($decoded); + $this->assertSame($long, str_replace("\u{200B}", '', $decoded)); } /** From e5844fa6288dc6eecf2dae0f62d828e575bf9d2b Mon Sep 17 00:00:00 2001 From: Pavel Zotikov Date: Thu, 23 Apr 2026 17:11:38 +0300 Subject: [PATCH 2/6] Update EventPayloadBuilderTest.php --- tests/Unit/EventPayloadBuilderTest.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/Unit/EventPayloadBuilderTest.php b/tests/Unit/EventPayloadBuilderTest.php index d01440d..71819ff 100644 --- a/tests/Unit/EventPayloadBuilderTest.php +++ b/tests/Unit/EventPayloadBuilderTest.php @@ -53,7 +53,7 @@ public function testNormalizeBacktraceLimitsArgumentCount(): void $frame = [ 'file' => '/x.php', 'line' => 1, - 'function' => 'not_registered_function_'.uniqid(), + 'function' => 'not_registered_function_' . uniqid(), 'args' => [1, 2, 3, 4, 5, 6, 7, 8, 9, 10], ]; From 058dfaa5fd53f890468fd3b18dfe3fd823174f5d Mon Sep 17 00:00:00 2001 From: Pavel Zotikov Date: Thu, 23 Apr 2026 17:24:21 +0300 Subject: [PATCH 3/6] Update src/StacktraceFrameBuilder.php Co-authored-by: Peter --- src/StacktraceFrameBuilder.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/StacktraceFrameBuilder.php b/src/StacktraceFrameBuilder.php index 391b63a..8406a6e 100644 --- a/src/StacktraceFrameBuilder.php +++ b/src/StacktraceFrameBuilder.php @@ -17,7 +17,7 @@ final class StacktraceFrameBuilder /** * Max function arguments to include per frame (payload size, CPU, Hawk limits). */ - public const MAX_FRAME_ARGUMENTS = 5; + public const MAX_FRAME_ARGUMENTS = 20; /** * Max length of one serialized "name = value" line (bytes; avoids huge JSON in events). From f94263069a446ae64d68771a29f6662d3697d244 Mon Sep 17 00:00:00 2001 From: Pavel Zotikov Date: Thu, 23 Apr 2026 17:44:19 +0300 Subject: [PATCH 4/6] refactor: cap stack arguments by count only; remove line truncation and ZWSP hints --- src/EventPayloadBuilder.php | 17 ++++---------- src/Serializer.php | 31 -------------------------- src/StacktraceFrameBuilder.php | 16 +------------ tests/Unit/EventPayloadBuilderTest.php | 12 +++++----- tests/Unit/SerializerTest.php | 11 --------- 5 files changed, 11 insertions(+), 76 deletions(-) diff --git a/src/EventPayloadBuilder.php b/src/EventPayloadBuilder.php index 89806c3..8e258b8 100644 --- a/src/EventPayloadBuilder.php +++ b/src/EventPayloadBuilder.php @@ -232,7 +232,8 @@ private function sanitizeArrayKeys($value) } /** - * Build Hawk `arguments` (string[]) from a frame: prefers ready `arguments`, else formats raw `args`. + * Build Hawk `arguments`: string list like "name = serializedValue" (from raw `args` via Serializer). + * Only the number of lines is limited ({@see StacktraceFrameBuilder::MAX_FRAME_ARGUMENTS}); lines are not truncated. * * @param array $frame * @@ -241,12 +242,11 @@ private function sanitizeArrayKeys($value) private function buildArgumentsList(array $frame): array { $max = StacktraceFrameBuilder::MAX_FRAME_ARGUMENTS; - $maxBytes = StacktraceFrameBuilder::MAX_ARGUMENT_LINE_BYTES; if (isset($frame['arguments']) && is_array($frame['arguments'])) { $out = []; foreach (array_slice($frame['arguments'], 0, $max) as $line) { - $out[] = $this->truncateArgumentLineString((string) $line, $maxBytes); + $out[] = (string) $line; } return $out; @@ -255,7 +255,7 @@ private function buildArgumentsList(array $frame): array if (!empty($frame['args']) && is_array($frame['args'])) { $out = []; foreach (array_slice($this->stacktraceFrameBuilder->getFormattedArguments($frame), 0, $max) as $line) { - $out[] = $this->truncateArgumentLineString((string) $line, $maxBytes); + $out[] = (string) $line; } return $out; @@ -264,15 +264,6 @@ private function buildArgumentsList(array $frame): array return []; } - private function truncateArgumentLineString(string $line, int $maxBytes): string - { - if (strlen($line) <= $maxBytes) { - return $line; - } - - return substr($line, 0, $maxBytes - 3) . '...'; - } - /** * Transform values to JSON-serializable representation * diff --git a/src/Serializer.php b/src/Serializer.php index d2014de..918e251 100644 --- a/src/Serializer.php +++ b/src/Serializer.php @@ -11,12 +11,6 @@ */ final class Serializer { - /** - * Long scalar strings: insert U+200B every N chars so UIs can wrap (like soft word-break), - * without breaking JSON validity. Does not alter tokens like short keys. - */ - private const SOFT_BREAK_EVERY_CHARS = 72; - /** * Process any value and makes it safe (in appropriate format) to send to hawk * @@ -88,36 +82,11 @@ private function transform($value) return get_class($value); } elseif (is_resource($value)) { return 'Resource'; - } elseif (is_string($value)) { - return $this->insertSoftBreaksInString($value); } else { return $value; } } - /** - * Insert zero-width spaces for long strings so Hawk (or any monospace view) can wrap - * without CSS word-break; JSON remains valid after json_encode. - */ - private function insertSoftBreaksInString(string $value): string - { - $len = strlen($value); - if ($len <= self::SOFT_BREAK_EVERY_CHARS) { - return $value; - } - - $chunk = self::SOFT_BREAK_EVERY_CHARS; - $zwsp = "\u{200B}"; - - if (function_exists('mb_str_split')) { - $parts = mb_str_split($value, $chunk, 'UTF-8'); - - return implode($zwsp, $parts); - } - - return implode($zwsp, str_split($value, $chunk)); - } - /** * Check array if it is associative * diff --git a/src/StacktraceFrameBuilder.php b/src/StacktraceFrameBuilder.php index 391b63a..3f7cacf 100644 --- a/src/StacktraceFrameBuilder.php +++ b/src/StacktraceFrameBuilder.php @@ -19,11 +19,6 @@ final class StacktraceFrameBuilder */ public const MAX_FRAME_ARGUMENTS = 5; - /** - * Max length of one serialized "name = value" line (bytes; avoids huge JSON in events). - */ - public const MAX_ARGUMENT_LINE_BYTES = 2048; - /** * @var Serializer */ @@ -277,7 +272,7 @@ private function getArgs(array $frame): array try { $line = sprintf('%s = %s', $name, $value); - $newArguments[] = $this->truncateArgumentLine($line); + $newArguments[] = $line; } catch (\Exception $e) { // Ignore unknown types } @@ -286,15 +281,6 @@ private function getArgs(array $frame): array return $newArguments; } - private function truncateArgumentLine(string $line): string - { - if (strlen($line) <= self::MAX_ARGUMENT_LINE_BYTES) { - return $line; - } - - return substr($line, 0, self::MAX_ARGUMENT_LINE_BYTES - 3) . '...'; - } - /** * Trying to create a reflection method * diff --git a/tests/Unit/EventPayloadBuilderTest.php b/tests/Unit/EventPayloadBuilderTest.php index 71819ff..3b854b2 100644 --- a/tests/Unit/EventPayloadBuilderTest.php +++ b/tests/Unit/EventPayloadBuilderTest.php @@ -49,23 +49,24 @@ public function testNormalizeBacktraceDoesNotPutRawArgsIntoAdditionalData(): voi public function testNormalizeBacktraceLimitsArgumentCount(): void { [$builder, $normalize] = $this->builderWithNormalizeBacktrace(); + $max = StacktraceFrameBuilder::MAX_FRAME_ARGUMENTS; $frame = [ 'file' => '/x.php', 'line' => 1, 'function' => 'not_registered_function_' . uniqid(), - 'args' => [1, 2, 3, 4, 5, 6, 7, 8, 9, 10], + 'args' => array_values(range(1, $max + 5)), ]; $stack = $normalize->invoke($builder, [$frame]); - $this->assertCount(StacktraceFrameBuilder::MAX_FRAME_ARGUMENTS, $stack[0]['arguments']); + $this->assertCount($max, $stack[0]['arguments']); } - public function testNormalizeBacktraceTruncatesPrebuiltArgumentLines(): void + public function testNormalizeBacktracePreservesPrebuiltArgumentLinesWithoutTrimming(): void { [$builder, $normalize] = $this->builderWithNormalizeBacktrace(); - $long = str_repeat('Z', StacktraceFrameBuilder::MAX_ARGUMENT_LINE_BYTES + 100); + $long = str_repeat('Z', 5000); $frame = [ 'file' => '/x.php', @@ -77,8 +78,7 @@ public function testNormalizeBacktraceTruncatesPrebuiltArgumentLines(): void $stack = $normalize->invoke($builder, [$frame]); $line = $stack[0]['arguments'][0] ?? ''; - $this->assertLessThanOrEqual(StacktraceFrameBuilder::MAX_ARGUMENT_LINE_BYTES, strlen($line)); - $this->assertStringEndsWith('...', $line); + $this->assertSame($long, $line); } public function testNormalizeBacktraceFinishesWithGlobalsLikeNestingInAdditionalData(): void diff --git a/tests/Unit/SerializerTest.php b/tests/Unit/SerializerTest.php index 89ac9f8..550d092 100644 --- a/tests/Unit/SerializerTest.php +++ b/tests/Unit/SerializerTest.php @@ -59,17 +59,6 @@ public function testSerializationWithLargeArray(): void $this->assertIsArray(json_decode($result, true)); } - public function testLongStringValuesGetSoftBreakPointsForDisplay(): void - { - $long = str_repeat('a', 200); - $fixture = new Serializer(); - $result = $fixture->serializeValue($long); - $this->assertStringContainsString("\u{200B}", $result); - $decoded = json_decode($result, false, 512, JSON_THROW_ON_ERROR); - $this->assertIsString($decoded); - $this->assertSame($long, str_replace("\u{200B}", '', $decoded)); - } - /** * Fills empty array with values: * [1 => [2 => [3 => ....]]] From 687d4461520fa34df802c9e62111b0340cbb5ad8 Mon Sep 17 00:00:00 2001 From: Pavel Zotikov Date: Fri, 24 Apr 2026 11:30:35 +0300 Subject: [PATCH 5/6] fix: truncate stack frame argument lines to MAX_ARGUMENT_LINE_BYTES --- src/EventPayloadBuilder.php | 17 ++++++++++++++--- src/StacktraceFrameBuilder.php | 16 +++++++++++++++- tests/Unit/EventPayloadBuilderTest.php | 7 ++++--- 3 files changed, 33 insertions(+), 7 deletions(-) diff --git a/src/EventPayloadBuilder.php b/src/EventPayloadBuilder.php index 8e258b8..46e1f3c 100644 --- a/src/EventPayloadBuilder.php +++ b/src/EventPayloadBuilder.php @@ -233,7 +233,8 @@ private function sanitizeArrayKeys($value) /** * Build Hawk `arguments`: string list like "name = serializedValue" (from raw `args` via Serializer). - * Only the number of lines is limited ({@see StacktraceFrameBuilder::MAX_FRAME_ARGUMENTS}); lines are not truncated. + * Limits the number of lines ({@see StacktraceFrameBuilder::MAX_FRAME_ARGUMENTS}) and each line's byte length + * ({@see StacktraceFrameBuilder::MAX_ARGUMENT_LINE_BYTES}) for payload size. * * @param array $frame * @@ -242,11 +243,12 @@ private function sanitizeArrayKeys($value) private function buildArgumentsList(array $frame): array { $max = StacktraceFrameBuilder::MAX_FRAME_ARGUMENTS; + $maxBytes = StacktraceFrameBuilder::MAX_ARGUMENT_LINE_BYTES; if (isset($frame['arguments']) && is_array($frame['arguments'])) { $out = []; foreach (array_slice($frame['arguments'], 0, $max) as $line) { - $out[] = (string) $line; + $out[] = $this->truncateArgumentLineString((string) $line, $maxBytes); } return $out; @@ -255,7 +257,7 @@ private function buildArgumentsList(array $frame): array if (!empty($frame['args']) && is_array($frame['args'])) { $out = []; foreach (array_slice($this->stacktraceFrameBuilder->getFormattedArguments($frame), 0, $max) as $line) { - $out[] = (string) $line; + $out[] = $this->truncateArgumentLineString((string) $line, $maxBytes); } return $out; @@ -264,6 +266,15 @@ private function buildArgumentsList(array $frame): array return []; } + private function truncateArgumentLineString(string $line, int $maxBytes): string + { + if (strlen($line) <= $maxBytes) { + return $line; + } + + return substr($line, 0, $maxBytes - 3) . '...'; + } + /** * Transform values to JSON-serializable representation * diff --git a/src/StacktraceFrameBuilder.php b/src/StacktraceFrameBuilder.php index 1377251..8406a6e 100644 --- a/src/StacktraceFrameBuilder.php +++ b/src/StacktraceFrameBuilder.php @@ -19,6 +19,11 @@ final class StacktraceFrameBuilder */ public const MAX_FRAME_ARGUMENTS = 20; + /** + * Max length of one serialized "name = value" line (bytes; avoids huge JSON in events). + */ + public const MAX_ARGUMENT_LINE_BYTES = 2048; + /** * @var Serializer */ @@ -272,7 +277,7 @@ private function getArgs(array $frame): array try { $line = sprintf('%s = %s', $name, $value); - $newArguments[] = $line; + $newArguments[] = $this->truncateArgumentLine($line); } catch (\Exception $e) { // Ignore unknown types } @@ -281,6 +286,15 @@ private function getArgs(array $frame): array return $newArguments; } + private function truncateArgumentLine(string $line): string + { + if (strlen($line) <= self::MAX_ARGUMENT_LINE_BYTES) { + return $line; + } + + return substr($line, 0, self::MAX_ARGUMENT_LINE_BYTES - 3) . '...'; + } + /** * Trying to create a reflection method * diff --git a/tests/Unit/EventPayloadBuilderTest.php b/tests/Unit/EventPayloadBuilderTest.php index 3b854b2..49f0920 100644 --- a/tests/Unit/EventPayloadBuilderTest.php +++ b/tests/Unit/EventPayloadBuilderTest.php @@ -63,10 +63,10 @@ public function testNormalizeBacktraceLimitsArgumentCount(): void $this->assertCount($max, $stack[0]['arguments']); } - public function testNormalizeBacktracePreservesPrebuiltArgumentLinesWithoutTrimming(): void + public function testNormalizeBacktraceTruncatesPrebuiltArgumentLines(): void { [$builder, $normalize] = $this->builderWithNormalizeBacktrace(); - $long = str_repeat('Z', 5000); + $long = str_repeat('Z', StacktraceFrameBuilder::MAX_ARGUMENT_LINE_BYTES + 100); $frame = [ 'file' => '/x.php', @@ -78,7 +78,8 @@ public function testNormalizeBacktracePreservesPrebuiltArgumentLinesWithoutTrimm $stack = $normalize->invoke($builder, [$frame]); $line = $stack[0]['arguments'][0] ?? ''; - $this->assertSame($long, $line); + $this->assertLessThanOrEqual(StacktraceFrameBuilder::MAX_ARGUMENT_LINE_BYTES, strlen($line)); + $this->assertStringEndsWith('...', $line); } public function testNormalizeBacktraceFinishesWithGlobalsLikeNestingInAdditionalData(): void From fb2293b0552436cb07a506f8fadb92c9066dcc48 Mon Sep 17 00:00:00 2001 From: Pavel Zotikov Date: Tue, 28 Apr 2026 14:40:13 +0300 Subject: [PATCH 6/6] fix(stacktrace): keep full serialized JSON in frame arguments - Stop truncating the value side of `name = serializedValue`; only cap parameter names (MAX_ARGUMENT_NAME_BYTES). - Prebuilt argument lines without ` = ` pass through untouched. - Remove MAX_ARGUMENT_LINE_BYTES / MAX_ARGUMENT_VALUE_BYTES; update tests. --- src/EventPayloadBuilder.php | 19 +++----- src/Serializer.php | 21 +++++++-- src/StacktraceFrameBuilder.php | 62 +++++++++++++++++++++++--- tests/Unit/EventPayloadBuilderTest.php | 32 +++++++++++-- tests/Unit/SerializerTest.php | 22 +++++++++ 5 files changed, 128 insertions(+), 28 deletions(-) diff --git a/src/EventPayloadBuilder.php b/src/EventPayloadBuilder.php index 46e1f3c..c1f71f9 100644 --- a/src/EventPayloadBuilder.php +++ b/src/EventPayloadBuilder.php @@ -233,8 +233,9 @@ private function sanitizeArrayKeys($value) /** * Build Hawk `arguments`: string list like "name = serializedValue" (from raw `args` via Serializer). - * Limits the number of lines ({@see StacktraceFrameBuilder::MAX_FRAME_ARGUMENTS}) and each line's byte length - * ({@see StacktraceFrameBuilder::MAX_ARGUMENT_LINE_BYTES}) for payload size. + * Limits the number of lines ({@see StacktraceFrameBuilder::MAX_FRAME_ARGUMENTS}). Serialized values are not + * length-truncated; only param names are capped ({@see StacktraceFrameBuilder::formatTruncatedArgumentLine}); + * prebuilt strings are split on the first `" = "` with {@see StacktraceFrameBuilder::truncatePrebuiltArgumentLine}. * * @param array $frame * @@ -243,12 +244,11 @@ private function sanitizeArrayKeys($value) private function buildArgumentsList(array $frame): array { $max = StacktraceFrameBuilder::MAX_FRAME_ARGUMENTS; - $maxBytes = StacktraceFrameBuilder::MAX_ARGUMENT_LINE_BYTES; if (isset($frame['arguments']) && is_array($frame['arguments'])) { $out = []; foreach (array_slice($frame['arguments'], 0, $max) as $line) { - $out[] = $this->truncateArgumentLineString((string) $line, $maxBytes); + $out[] = StacktraceFrameBuilder::truncatePrebuiltArgumentLine((string) $line); } return $out; @@ -257,7 +257,7 @@ private function buildArgumentsList(array $frame): array if (!empty($frame['args']) && is_array($frame['args'])) { $out = []; foreach (array_slice($this->stacktraceFrameBuilder->getFormattedArguments($frame), 0, $max) as $line) { - $out[] = $this->truncateArgumentLineString((string) $line, $maxBytes); + $out[] = (string) $line; } return $out; @@ -266,15 +266,6 @@ private function buildArgumentsList(array $frame): array return []; } - private function truncateArgumentLineString(string $line, int $maxBytes): string - { - if (strlen($line) <= $maxBytes) { - return $line; - } - - return substr($line, 0, $maxBytes - 3) . '...'; - } - /** * Transform values to JSON-serializable representation * diff --git a/src/Serializer.php b/src/Serializer.php index 918e251..14e67f7 100644 --- a/src/Serializer.php +++ b/src/Serializer.php @@ -21,7 +21,8 @@ final class Serializer public function serializeValue($value): string { $flags = JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT; - $encoded = json_encode($this->prepare($value, 0), $flags); + $stack = []; + $encoded = json_encode($this->prepare($value, 0, $stack), $flags); if ($encoded === false) { return ''; @@ -40,25 +41,39 @@ public function serializeValue($value): string * * @param mixed $value * @param int $depth + * @param array $stack reference path (arrays only) to detect $GLOBALS-style cycles * * @return array|mixed|string */ - private function prepare($value, int $depth = 0) + private function prepare($value, int $depth, array &$stack) { if ($depth > self::PREPARE_MAX_DEPTH) { return '[max depth]'; } if (!is_object($value) && (is_array($value) || is_iterable($value))) { + if (is_array($value)) { + foreach ($stack as $ancestor) { + if ($value === $ancestor) { + return '[circular]'; + } + } + $stack[] = $value; + } + $result = []; foreach ($value as $key => $subValue) { if (is_array($subValue) || is_iterable($subValue)) { - $result[$key] = $this->prepare($subValue, $depth + 1); + $result[$key] = $this->prepare($subValue, $depth + 1, $stack); } else { $result[$key] = $this->transform($subValue); } } + if (is_array($value)) { + array_pop($stack); + } + return $result; } diff --git a/src/StacktraceFrameBuilder.php b/src/StacktraceFrameBuilder.php index 8406a6e..f678829 100644 --- a/src/StacktraceFrameBuilder.php +++ b/src/StacktraceFrameBuilder.php @@ -20,9 +20,10 @@ final class StacktraceFrameBuilder public const MAX_FRAME_ARGUMENTS = 20; /** - * Max length of one serialized "name = value" line (bytes; avoids huge JSON in events). + * Max UTF-8 byte length for the argument name (left-hand side only). + * Serialized values from {@see Serializer::serializeValue()} are not length-capped so JSON stays intact. */ - public const MAX_ARGUMENT_LINE_BYTES = 2048; + public const MAX_ARGUMENT_NAME_BYTES = 256; /** * @var Serializer @@ -276,8 +277,7 @@ private function getArgs(array $frame): array $value = $this->serializer->serializeValue($value); try { - $line = sprintf('%s = %s', $name, $value); - $newArguments[] = $this->truncateArgumentLine($line); + $newArguments[] = self::formatTruncatedArgumentLine((string) $name, $value); } catch (\Exception $e) { // Ignore unknown types } @@ -286,13 +286,61 @@ private function getArgs(array $frame): array return $newArguments; } - private function truncateArgumentLine(string $line): string + /** + * Build `"name = value"` — only the name may be shortened; serialized value is kept whole (valid JSON, etc.). + */ + public static function formatTruncatedArgumentLine(string $name, string $serializedValue): string { - if (strlen($line) <= self::MAX_ARGUMENT_LINE_BYTES) { + $namePart = self::truncateUtf8StringToMaxBytes($name, self::MAX_ARGUMENT_NAME_BYTES); + + return $namePart . ' = ' . $serializedValue; + } + + /** + * Normalize one prebuilt `name = serializedValue` line: split at the first `" = "`, cap name only; value unchanged. + * Lines without `" = "` are returned as-is (no length limit). + */ + public static function truncatePrebuiltArgumentLine(string $line): string + { + $separator = ' = '; + $position = strpos($line, $separator); + if ($position === false) { return $line; } - return substr($line, 0, self::MAX_ARGUMENT_LINE_BYTES - 3) . '...'; + $nameRaw = substr($line, 0, $position); + $valueRaw = substr($line, $position + strlen($separator)); + + return self::formatTruncatedArgumentLine($nameRaw, $valueRaw); + } + + /** + * Shorten text to byte length (`...` suffix when clipped); Unicode-safe via {@see mb_strcut} when mbstring exists. + */ + public static function truncateUtf8StringToMaxBytes(string $string, int $maxBytes): string + { + if ($maxBytes <= 3) { + return substr('...', 0, $maxBytes); + } + + if (strlen($string) <= $maxBytes) { + return $string; + } + + $cutLength = $maxBytes - 3; + if (function_exists('mb_strcut')) { + return mb_strcut($string, 0, $cutLength, 'UTF-8') . '...'; + } + + return substr($string, 0, $cutLength) . '...'; + } + + /** + * @deprecated Use {@see truncateUtf8StringToMaxBytes} or {@see formatTruncatedArgumentLine} + */ + public static function truncateArgumentLineBytes(string $line, int $maxBytes): string + { + return self::truncateUtf8StringToMaxBytes($line, $maxBytes); } /** diff --git a/tests/Unit/EventPayloadBuilderTest.php b/tests/Unit/EventPayloadBuilderTest.php index 49f0920..dac011a 100644 --- a/tests/Unit/EventPayloadBuilderTest.php +++ b/tests/Unit/EventPayloadBuilderTest.php @@ -63,10 +63,10 @@ public function testNormalizeBacktraceLimitsArgumentCount(): void $this->assertCount($max, $stack[0]['arguments']); } - public function testNormalizeBacktraceTruncatesPrebuiltArgumentLines(): void + public function testNormalizeBacktracePreservesPrebuiltArgumentLineWithoutDelimiter(): void { [$builder, $normalize] = $this->builderWithNormalizeBacktrace(); - $long = str_repeat('Z', StacktraceFrameBuilder::MAX_ARGUMENT_LINE_BYTES + 100); + $long = str_repeat('Z', 50_000); $frame = [ 'file' => '/x.php', @@ -78,8 +78,32 @@ public function testNormalizeBacktraceTruncatesPrebuiltArgumentLines(): void $stack = $normalize->invoke($builder, [$frame]); $line = $stack[0]['arguments'][0] ?? ''; - $this->assertLessThanOrEqual(StacktraceFrameBuilder::MAX_ARGUMENT_LINE_BYTES, strlen($line)); - $this->assertStringEndsWith('...', $line); + $this->assertSame($long, $line); + } + + public function testNormalizeBacktraceTruncatesPrebuiltNameOnlyValuePreserved(): void + { + [$builder, $normalize] = $this->builderWithNormalizeBacktrace(); + $longName = str_repeat('K', StacktraceFrameBuilder::MAX_ARGUMENT_NAME_BYTES + 50); + $longValue = str_repeat('V', 12_345); + $prebuiltLine = $longName . ' = ' . $longValue; + + $frame = [ + 'file' => '/x.php', + 'line' => 1, + 'function' => 'f', + 'arguments' => [$prebuiltLine], + ]; + + $stack = $normalize->invoke($builder, [$frame]); + $line = $stack[0]['arguments'][0] ?? ''; + $parts = explode(' = ', $line, 2); + $this->assertCount(2, $parts); + [$namePart, $valuePart] = $parts; + + $this->assertLessThanOrEqual(StacktraceFrameBuilder::MAX_ARGUMENT_NAME_BYTES, strlen($namePart)); + $this->assertStringEndsWith('...', $namePart); + $this->assertSame($longValue, $valuePart); } public function testNormalizeBacktraceFinishesWithGlobalsLikeNestingInAdditionalData(): void diff --git a/tests/Unit/SerializerTest.php b/tests/Unit/SerializerTest.php index 550d092..3f82ee3 100644 --- a/tests/Unit/SerializerTest.php +++ b/tests/Unit/SerializerTest.php @@ -59,6 +59,28 @@ public function testSerializationWithLargeArray(): void $this->assertIsArray(json_decode($result, true)); } + public function testSerializationReplacesCircularArrayReferences(): void + { + $globalsLike = []; + $globalsLike['GLOBALS'] = &$globalsLike; + $globalsLike['_marker'] = 'e2e'; + + $fixture = new Serializer(); + $decoded = json_decode($fixture->serializeValue($globalsLike), true, 512, JSON_THROW_ON_ERROR); + + $this->assertSame('[circular]', $decoded['GLOBALS']); + $this->assertSame('e2e', $decoded['_marker']); + } + + public function testSerializationPreservesLongScalarStringsForRoundTripJson(): void + { + $long = \str_repeat("word spaced text ", 280); + $fixture = new Serializer(); + $decoded = json_decode($fixture->serializeValue(['blob' => $long]), true, 512, JSON_THROW_ON_ERROR); + + $this->assertSame($long, $decoded['blob']); + } + /** * Fills empty array with values: * [1 => [2 => [3 => ....]]]