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') }} diff --git a/src/EventPayloadBuilder.php b/src/EventPayloadBuilder.php index 74d382a..99b6269 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. */ @@ -76,6 +89,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 +126,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']; + } + + $additional = []; + foreach ($frame as $key => $value) { + 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; + } + + $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 + // 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, + ]); + } + + 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; + } }