From b8bc0d153f5d6c25e8d08dede2144b28068543fb Mon Sep 17 00:00:00 2001 From: Pavel Zotikov Date: Thu, 25 Sep 2025 12:52:36 +0300 Subject: [PATCH 1/7] feat: add stacktrace normalization and safe serialization helpers --- src/EventPayloadBuilder.php | 138 ++++++++++++++++++++++++++++++++++++ 1 file changed, 138 insertions(+) diff --git a/src/EventPayloadBuilder.php b/src/EventPayloadBuilder.php index 74d382a..53234f9 100644 --- a/src/EventPayloadBuilder.php +++ b/src/EventPayloadBuilder.php @@ -76,6 +76,12 @@ public function create(array $data): EventPayload $stacktrace = debug_backtrace(); } + /** + * Normalize frames to BacktraceFrame shape and wrap extra fields in additionalData. + * Also sanitize keys for MongoDB compatibility. + */ + $stacktrace = $this->normalizeBacktrace($stacktrace); + if (isset($data['type'])) { $eventPayload->setType($data['type']); } @@ -107,4 +113,136 @@ private function resolveAddons(): array return $result; } + + /** + * Normalize any stacktrace representation to BacktraceFrame shape + * and wrap unknown fields into additionalData with safe keys + * + * @param array $stack + * + * @return array + */ + private function normalizeBacktrace(array $stack): array + { + $normalized = []; + + foreach ($stack as $frame) { + if (!is_array($frame)) { + continue; + } + + $file = isset($frame['file']) ? (string)$frame['file'] : ''; + $line = isset($frame['line']) ? (int)$frame['line'] : 0; + $functionName = null; + + if (isset($frame['function'])) { + if (!empty($frame['class']) && !empty($frame['type'])) { + $functionName = (string)$frame['class'] . (string)$frame['type'] . (string)$frame['function']; + } else { + $functionName = (string)$frame['function']; + } + } elseif (isset($frame['functionName'])) { + $functionName = (string)$frame['functionName']; + } + + $allowedKeys = ['file', 'line', 'column', 'sourceCode', 'function', 'arguments', 'additionalData']; + + $additional = []; + foreach ($frame as $key => $value) { + if (!in_array($key, $allowedKeys, true)) { + // 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); + } + } + + $normalized[] = $this->sanitizeArrayKeys([ + 'file' => $file, + 'line' => $line, + '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 + 'arguments' => (isset($frame['arguments']) && is_array($frame['arguments'])) ? array_values(array_map('strval', $frame['arguments'])) : [], + 'additionalData'=> $additional, + ]); + } + + return $normalized; + } + + /** + * Recursively sanitize array keys to be MongoDB-safe + * - replace dots with underscores + * - replace leading '$' with 'dollar_' + * + * @param mixed $value + * + * @return mixed + */ + private function sanitizeArrayKeys($value) + { + if (!is_array($value)) { + return $value; + } + + $sanitized = []; + + foreach ($value as $key => $subValue) { + $newKey = $key; + + if (is_string($newKey)) { + if (strpos($newKey, '.') !== false) { + $newKey = str_replace('.', '_', $newKey); + } + + if (isset($newKey[0]) && $newKey[0] === '$') { + $newKey = 'dollar_' . substr($newKey, 1); + } + } + + $sanitized[$newKey] = $this->sanitizeArrayKeys($subValue); + } + + return $sanitized; + } + + /** + * Transform values to JSON-serializable representation + * + * @param mixed $value + * + * @return mixed + */ + private function transformForJson($value) + { + if (is_array($value)) { + $result = []; + foreach ($value as $k => $v) { + $result[$k] = $this->transformForJson($v); + } + return $result; + } + + if (is_null($value)) { + return 'null'; + } + + if (is_callable($value)) { + return 'Closure'; + } + + if (is_object($value)) { + return get_class($value); + } + + if (is_resource($value)) { + return 'Resource'; + } + + return $value; + } } From e18f686d9947fb1916082cb4747564eca63636d2 Mon Sep 17 00:00:00 2001 From: Pavel Zotikov Date: Thu, 25 Sep 2025 12:57:50 +0300 Subject: [PATCH 2/7] change cache version for git workflows --- .github/workflows/ci.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 4ecac00..717c8d1 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -42,7 +42,7 @@ jobs: # Cache packages - name: Cache Composer packages id: composer-cache - uses: actions/cache@v2 + uses: actions/cache@v4 with: path: vendor key: ${{ runner.os }}-php-${{ hashFiles('**/composer.lock') }} From 641a470232f00d8580fe963c1839739759656491 Mon Sep 17 00:00:00 2001 From: Pavel Zotikov Date: Thu, 25 Sep 2025 13:00:30 +0300 Subject: [PATCH 3/7] linter --- src/EventPayloadBuilder.php | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/src/EventPayloadBuilder.php b/src/EventPayloadBuilder.php index 53234f9..790d88b 100644 --- a/src/EventPayloadBuilder.php +++ b/src/EventPayloadBuilder.php @@ -131,18 +131,18 @@ private function normalizeBacktrace(array $stack): array continue; } - $file = isset($frame['file']) ? (string)$frame['file'] : ''; - $line = isset($frame['line']) ? (int)$frame['line'] : 0; + $file = isset($frame['file']) ? (string) $frame['file'] : ''; + $line = isset($frame['line']) ? (int) $frame['line'] : 0; $functionName = null; if (isset($frame['function'])) { if (!empty($frame['class']) && !empty($frame['type'])) { - $functionName = (string)$frame['class'] . (string)$frame['type'] . (string)$frame['function']; + $functionName = (string) $frame['class'] . (string) $frame['type'] . (string) $frame['function']; } else { - $functionName = (string)$frame['function']; + $functionName = (string) $frame['function']; } } elseif (isset($frame['functionName'])) { - $functionName = (string)$frame['functionName']; + $functionName = (string) $frame['functionName']; } $allowedKeys = ['file', 'line', 'column', 'sourceCode', 'function', 'arguments', 'additionalData']; @@ -224,6 +224,7 @@ private function transformForJson($value) foreach ($value as $k => $v) { $result[$k] = $this->transformForJson($v); } + return $result; } From 9ee87b6541b40c942327d740fecfa8a709ff3fc9 Mon Sep 17 00:00:00 2001 From: Pavel Zotikov Date: Thu, 25 Sep 2025 13:43:22 +0300 Subject: [PATCH 4/7] Update src/EventPayloadBuilder.php Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/EventPayloadBuilder.php | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/EventPayloadBuilder.php b/src/EventPayloadBuilder.php index 790d88b..02e1201 100644 --- a/src/EventPayloadBuilder.php +++ b/src/EventPayloadBuilder.php @@ -166,7 +166,10 @@ private function normalizeBacktrace(array $stack): array '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 - 'arguments' => (isset($frame['arguments']) && is_array($frame['arguments'])) ? array_values(array_map('strval', $frame['arguments'])) : [], + // 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))) + : [], 'additionalData'=> $additional, ]); } From e4881c831e33942c185e2b85556ca5501937e570 Mon Sep 17 00:00:00 2001 From: Pavel Zotikov Date: Thu, 25 Sep 2025 13:47:07 +0300 Subject: [PATCH 5/7] Update EventPayloadBuilder.php --- src/EventPayloadBuilder.php | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/src/EventPayloadBuilder.php b/src/EventPayloadBuilder.php index 790d88b..1fde76f 100644 --- a/src/EventPayloadBuilder.php +++ b/src/EventPayloadBuilder.php @@ -27,6 +27,19 @@ class EventPayloadBuilder */ private $stacktraceFrameBuilder; + /** + * Allowed keys for stacktrace frames + */ + private const ALLOWED_KEYS = [ + 'file', + 'line', + 'column', + 'sourceCode', + 'function', + 'arguments', + 'additionalData', + ]; + /** * EventPayloadFactory constructor. */ @@ -145,11 +158,9 @@ private function normalizeBacktrace(array $stack): array $functionName = (string) $frame['functionName']; } - $allowedKeys = ['file', 'line', 'column', 'sourceCode', 'function', 'arguments', 'additionalData']; - $additional = []; foreach ($frame as $key => $value) { - if (!in_array($key, $allowedKeys, true)) { + if (!in_array($key, self::ALLOWED_KEYS, true)) { // Drop heavy/unserializable objects from 'object' field; store class name instead if ($key === 'object') { $value = is_object($value) ? get_class($value) : $value; From 18efbe9d8eb75a2b62732de86e57eb28ff452d4d Mon Sep 17 00:00:00 2001 From: Pavel Zotikov Date: Thu, 25 Sep 2025 14:12:49 +0300 Subject: [PATCH 6/7] Update EventPayloadBuilder.php --- src/EventPayloadBuilder.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/EventPayloadBuilder.php b/src/EventPayloadBuilder.php index 2b118ea..3d94b57 100644 --- a/src/EventPayloadBuilder.php +++ b/src/EventPayloadBuilder.php @@ -243,7 +243,7 @@ private function transformForJson($value) } if (is_null($value)) { - return 'null'; + return null; } if (is_callable($value)) { From aadcfb2b52712e06633e30529d29177a4f065e83 Mon Sep 17 00:00:00 2001 From: Pavel Zotikov Date: Thu, 25 Sep 2025 14:16:11 +0300 Subject: [PATCH 7/7] Update EventPayloadBuilder.php --- src/EventPayloadBuilder.php | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/EventPayloadBuilder.php b/src/EventPayloadBuilder.php index 3d94b57..99b6269 100644 --- a/src/EventPayloadBuilder.php +++ b/src/EventPayloadBuilder.php @@ -178,9 +178,7 @@ private function normalizeBacktrace(array $stack): array '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' => (isset($frame['arguments']) && is_array($frame['arguments'])) ? array_values(array_map('strval', array_slice($frame['arguments'], 0, 10))) : [], 'additionalData'=> $additional, ]); }