diff --git a/src/EventPayloadBuilder.php b/src/EventPayloadBuilder.php index 99b6269..c1f71f9 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,59 @@ private function sanitizeArrayKeys($value) return $sanitized; } + /** + * 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 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..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..f678829 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]; } @@ -246,15 +277,70 @@ private function getArgs(array $frame): array $value = $this->serializer->serializeValue($value); try { - $newArguments[] = sprintf('%s = %s', $name, $value); + $newArguments[] = self::formatTruncatedArgumentLine((string) $name, $value); } catch (\Exception $e) { // Ignore unknown types } } - $arguments = $newArguments; + 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); + } - return $arguments; + /** + * 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 02129d2..dac011a 100644 --- a/tests/Unit/EventPayloadBuilderTest.php +++ b/tests/Unit/EventPayloadBuilderTest.php @@ -13,6 +13,125 @@ 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 testCreationWithDefaultException(): void { $context = [ diff --git a/tests/Unit/SerializerTest.php b/tests/Unit/SerializerTest.php index e5d4691..3f82ee3 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']); } /**