diff --git a/src/EventPayloadBuilder.php b/src/EventPayloadBuilder.php index 99b6269..37024c4 100644 --- a/src/EventPayloadBuilder.php +++ b/src/EventPayloadBuilder.php @@ -40,6 +40,12 @@ class EventPayloadBuilder 'additionalData', ]; + /** + * {@see transformForJsonRecursive} max nesting — stops runaway recursion; combined with array reference + * stack for the same circular detection as {@see Serializer::prepare()}. + */ + private const TRANSFORM_JSON_MAX_DEPTH = 32; + /** * EventPayloadFactory constructor. */ @@ -158,9 +164,15 @@ 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; @@ -176,9 +188,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, ]); } @@ -223,7 +233,42 @@ private function sanitizeArrayKeys($value) } /** - * Transform values to JSON-serializable representation + * Build Hawk `arguments`: string list like "name = serializedValue" (from raw `args` via Serializer). + * 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 + * + * @return array + */ + private function buildArgumentsList(array $frame): array + { + $max = StacktraceFrameBuilder::MAX_FRAME_ARGUMENTS; + + if (isset($frame['arguments']) && is_array($frame['arguments'])) { + $out = []; + foreach (array_slice($frame['arguments'], 0, $max) as $line) { + $out[] = StacktraceFrameBuilder::truncatePrebuiltArgumentLine((string) $line); + } + + return $out; + } + + if (!empty($frame['args']) && is_array($frame['args'])) { + $out = []; + foreach (array_slice($this->stacktraceFrameBuilder->getFormattedArguments($frame), 0, $max) as $line) { + $out[] = (string) $line; + } + + return $out; + } + + return []; + } + + /** + * Transform frame extra fields for JSON — scalars, shallow objects, arrays with depth/cycle limits. * * @param mixed $value * @@ -231,11 +276,35 @@ private function sanitizeArrayKeys($value) */ private function transformForJson($value) { + $stack = []; + + return $this->transformForJsonRecursive($value, 0, $stack); + } + + /** + * @param mixed $value + * + * @return mixed + */ + private function transformForJsonRecursive($value, int $depth, array &$stack) + { + if ($depth > self::TRANSFORM_JSON_MAX_DEPTH) { + return '[max depth]'; + } + if (is_array($value)) { + foreach ($stack as $ancestor) { + if ($value === $ancestor) { + return '[circular]'; + } + } + + $stack[] = $value; $result = []; foreach ($value as $k => $v) { - $result[$k] = $this->transformForJson($v); + $result[$k] = $this->transformForJsonRecursive($v, $depth + 1, $stack); } + array_pop($stack); return $result; } diff --git a/src/Serializer.php b/src/Serializer.php index de642f9..14e67f7 100644 --- a/src/Serializer.php +++ b/src/Serializer.php @@ -20,7 +20,9 @@ final class Serializer */ public function serializeValue($value): string { - $encoded = json_encode($this->prepare($value), JSON_UNESCAPED_UNICODE); + $flags = JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT; + $stack = []; + $encoded = json_encode($this->prepare($value, 0, $stack), $flags); if ($encoded === false) { return ''; @@ -29,29 +31,53 @@ 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 + * @param array $stack reference path (arrays only) to detect $GLOBALS-style cycles * * @return array|mixed|string */ - private function prepare($value) + 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); + $result[$key] = $this->prepare($subValue, $depth + 1, $stack); } else { $result[$key] = $this->transform($subValue); } } + if (is_array($value)) { + array_pop($stack); + } + return $result; - } else { - return $this->transform($value); } + + return $this->transform($value); } /** diff --git a/src/StacktraceFrameBuilder.php b/src/StacktraceFrameBuilder.php index c2053d5..3f01812 100644 --- a/src/StacktraceFrameBuilder.php +++ b/src/StacktraceFrameBuilder.php @@ -14,6 +14,17 @@ */ final class StacktraceFrameBuilder { + /** + * Max function arguments to include per frame (payload size, CPU, Hawk limits). + */ + public const MAX_FRAME_ARGUMENTS = 20; + + /** + * 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_NAME_BYTES = 256; + /** * @var Serializer */ @@ -183,6 +194,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 +240,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 +258,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]; } @@ -243,18 +274,91 @@ private function getArgs(array $frame): array */ $newArguments = []; foreach ($arguments as $name => $value) { - $value = $this->serializer->serializeValue($value); + $serialized = $this->serializer->serializeValue($value); + $newArguments[] = self::formatTruncatedArgumentLine((string) $name, $serialized); + } - try { - $newArguments[] = sprintf('%s = %s', $name, $value); - } catch (\Exception $e) { - // Ignore unknown types - } + return $newArguments; + } + + /** + * 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 + { + $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; + } + + $nameRaw = substr($line, 0, $position); + $valueRaw = substr($line, $position + strlen($separator)); + + return self::formatTruncatedArgumentLine($nameRaw, $valueRaw); + } + + /** + * Longest prefix of $string whose byte length is at most $maxBytes and whose encoding is valid UTF-8. + * Used when {@see mb_strcut} is unavailable so {@see substr} never leaves a split codepoint (invalid JSON). + */ + private static function utf8SafePrefixMaxBytes(string $string, int $maxBytes): string + { + if ($maxBytes <= 0) { + return ''; + } + + if (strlen($string) <= $maxBytes) { + return $string; + } + + $s = substr($string, 0, $maxBytes); + while ($s !== '' && preg_match('//u', $s) !== 1) { + $s = substr($s, 0, -1); + } + + return $s; + } + + /** + * Shorten text to byte length (`...` suffix when clipped). Unicode-safe: {@see mb_strcut} when available, + * otherwise {@see utf8SafePrefixMaxBytes} (valid UTF-8 prefix, no split codepoints). + */ + 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') . '...'; } - $arguments = $newArguments; + return self::utf8SafePrefixMaxBytes($string, $cutLength) . '...'; + } - return $arguments; + /** + * @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 02129d2..c76a7b2 100644 --- a/tests/Unit/EventPayloadBuilderTest.php +++ b/tests/Unit/EventPayloadBuilderTest.php @@ -13,6 +13,147 @@ 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(); + $max = StacktraceFrameBuilder::MAX_FRAME_ARGUMENTS; + + $frame = [ + 'file' => '/x.php', + 'line' => 1, + 'function' => 'not_registered_function_' . uniqid(), + 'args' => array_values(range(1, $max + 5)), + ]; + + $stack = $normalize->invoke($builder, [$frame]); + + $this->assertCount($max, $stack[0]['arguments']); + } + + public function testNormalizeBacktracePreservesPrebuiltArgumentLineWithoutDelimiter(): void + { + [$builder, $normalize] = $this->builderWithNormalizeBacktrace(); + $long = str_repeat('Z', 50_000); + + $frame = [ + 'file' => '/x.php', + 'line' => 1, + 'function' => 'f', + 'arguments' => [$long], + ]; + + $stack = $normalize->invoke($builder, [$frame]); + $line = $stack[0]['arguments'][0] ?? ''; + + $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 + { + [$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 testNormalizeBacktraceMarksCircularArraysInAdditionalData(): void + { + [$builder, $normalize] = $this->builderWithNormalizeBacktrace(); + + $globalsLike = ['marker' => true]; + $globalsLike['GLOBALS'] = &$globalsLike; + + $frame = [ + 'file' => '/x.php', + 'line' => 1, + 'function' => 'x', + 'args' => [], + 'custom' => $globalsLike, + ]; + + $stack = $normalize->invoke($builder, [$frame]); + $custom = $stack[0]['additionalData']['custom'] ?? null; + $this->assertIsArray($custom); + $this->assertTrue($custom['marker']); + $this->assertSame('[circular]', $custom['GLOBALS']); + } + public function testCreationWithDefaultException(): void { $context = [ diff --git a/tests/Unit/SerializerTest.php b/tests/Unit/SerializerTest.php index e5d4691..97d3846 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,45 @@ 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 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']); } /** diff --git a/tests/Unit/StacktraceFrameBuilderTest.php b/tests/Unit/StacktraceFrameBuilderTest.php index 554ed40..dac4982 100644 --- a/tests/Unit/StacktraceFrameBuilderTest.php +++ b/tests/Unit/StacktraceFrameBuilderTest.php @@ -10,6 +10,26 @@ class StacktraceFrameBuilderTest extends TestCase { + public function testTruncateUtf8StringToMaxBytesPreservesValidUtf8WhenCuttingMultibyte(): void + { + $ru = str_repeat('П', 200); + $out = StacktraceFrameBuilder::truncateUtf8StringToMaxBytes($ru, 80); + $this->assertLessThanOrEqual(80, strlen($out)); + $this->assertStringEndsWith('...', $out); + $this->assertSame(1, preg_match('//u', $out), 'output must be valid UTF-8 for JSON'); + $this->assertNotFalse(json_encode($out, JSON_UNESCAPED_UNICODE)); + } + + public function testUtf8SafePrefixMaxBytesViaReflectionDoesNotSplitTwoByteChar(): void + { + $method = new \ReflectionMethod(StacktraceFrameBuilder::class, 'utf8SafePrefixMaxBytes'); + $method->setAccessible(true); + // 'П' is U+041F, two bytes in UTF-8 + $this->assertSame('', $method->invoke(null, 'Президент', 1)); + $this->assertSame('П', $method->invoke(null, 'Президент', 2)); + $this->assertSame('Пр', $method->invoke(null, 'Президент', 4)); + } + public function testResultingStacktraceFrames(): void { $serializer = new Serializer();