Skip to content

Commit fa86c6c

Browse files
authored
Merge pull request #70 from codex-team/feat/normalize-backtrace
Normalize stacktraces and sanitize data for safer storage
2 parents 736dc74 + aadcfb2 commit fa86c6c

File tree

2 files changed

+152
-1
lines changed

2 files changed

+152
-1
lines changed

.github/workflows/ci.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ jobs:
4242
# Cache packages
4343
- name: Cache Composer packages
4444
id: composer-cache
45-
uses: actions/cache@v2
45+
uses: actions/cache@v4
4646
with:
4747
path: vendor
4848
key: ${{ runner.os }}-php-${{ hashFiles('**/composer.lock') }}

src/EventPayloadBuilder.php

Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,19 @@ class EventPayloadBuilder
2727
*/
2828
private $stacktraceFrameBuilder;
2929

30+
/**
31+
* Allowed keys for stacktrace frames
32+
*/
33+
private const ALLOWED_KEYS = [
34+
'file',
35+
'line',
36+
'column',
37+
'sourceCode',
38+
'function',
39+
'arguments',
40+
'additionalData',
41+
];
42+
3043
/**
3144
* EventPayloadFactory constructor.
3245
*/
@@ -76,6 +89,12 @@ public function create(array $data): EventPayload
7689
$stacktrace = debug_backtrace();
7790
}
7891

92+
/**
93+
* Normalize frames to BacktraceFrame shape and wrap extra fields in additionalData.
94+
* Also sanitize keys for MongoDB compatibility.
95+
*/
96+
$stacktrace = $this->normalizeBacktrace($stacktrace);
97+
7998
if (isset($data['type'])) {
8099
$eventPayload->setType($data['type']);
81100
}
@@ -107,4 +126,136 @@ private function resolveAddons(): array
107126

108127
return $result;
109128
}
129+
130+
/**
131+
* Normalize any stacktrace representation to BacktraceFrame shape
132+
* and wrap unknown fields into additionalData with safe keys
133+
*
134+
* @param array $stack
135+
*
136+
* @return array
137+
*/
138+
private function normalizeBacktrace(array $stack): array
139+
{
140+
$normalized = [];
141+
142+
foreach ($stack as $frame) {
143+
if (!is_array($frame)) {
144+
continue;
145+
}
146+
147+
$file = isset($frame['file']) ? (string) $frame['file'] : '';
148+
$line = isset($frame['line']) ? (int) $frame['line'] : 0;
149+
$functionName = null;
150+
151+
if (isset($frame['function'])) {
152+
if (!empty($frame['class']) && !empty($frame['type'])) {
153+
$functionName = (string) $frame['class'] . (string) $frame['type'] . (string) $frame['function'];
154+
} else {
155+
$functionName = (string) $frame['function'];
156+
}
157+
} elseif (isset($frame['functionName'])) {
158+
$functionName = (string) $frame['functionName'];
159+
}
160+
161+
$additional = [];
162+
foreach ($frame as $key => $value) {
163+
if (!in_array($key, self::ALLOWED_KEYS, true)) {
164+
// Drop heavy/unserializable objects from 'object' field; store class name instead
165+
if ($key === 'object') {
166+
$value = is_object($value) ? get_class($value) : $value;
167+
}
168+
169+
$additional[$key] = $this->transformForJson($value);
170+
}
171+
}
172+
173+
$normalized[] = $this->sanitizeArrayKeys([
174+
'file' => $file,
175+
'line' => $line,
176+
'column' => null,
177+
'sourceCode' => isset($frame['sourceCode']) && is_array($frame['sourceCode']) ? $frame['sourceCode'] : null,
178+
'function' => $functionName,
179+
// Keep arguments only if it already looks like desired string[]; otherwise omit
180+
// Limit argument processing to first 10 items to avoid performance issues
181+
'arguments' => (isset($frame['arguments']) && is_array($frame['arguments'])) ? array_values(array_map('strval', array_slice($frame['arguments'], 0, 10))) : [],
182+
'additionalData'=> $additional,
183+
]);
184+
}
185+
186+
return $normalized;
187+
}
188+
189+
/**
190+
* Recursively sanitize array keys to be MongoDB-safe
191+
* - replace dots with underscores
192+
* - replace leading '$' with 'dollar_'
193+
*
194+
* @param mixed $value
195+
*
196+
* @return mixed
197+
*/
198+
private function sanitizeArrayKeys($value)
199+
{
200+
if (!is_array($value)) {
201+
return $value;
202+
}
203+
204+
$sanitized = [];
205+
206+
foreach ($value as $key => $subValue) {
207+
$newKey = $key;
208+
209+
if (is_string($newKey)) {
210+
if (strpos($newKey, '.') !== false) {
211+
$newKey = str_replace('.', '_', $newKey);
212+
}
213+
214+
if (isset($newKey[0]) && $newKey[0] === '$') {
215+
$newKey = 'dollar_' . substr($newKey, 1);
216+
}
217+
}
218+
219+
$sanitized[$newKey] = $this->sanitizeArrayKeys($subValue);
220+
}
221+
222+
return $sanitized;
223+
}
224+
225+
/**
226+
* Transform values to JSON-serializable representation
227+
*
228+
* @param mixed $value
229+
*
230+
* @return mixed
231+
*/
232+
private function transformForJson($value)
233+
{
234+
if (is_array($value)) {
235+
$result = [];
236+
foreach ($value as $k => $v) {
237+
$result[$k] = $this->transformForJson($v);
238+
}
239+
240+
return $result;
241+
}
242+
243+
if (is_null($value)) {
244+
return null;
245+
}
246+
247+
if (is_callable($value)) {
248+
return 'Closure';
249+
}
250+
251+
if (is_object($value)) {
252+
return get_class($value);
253+
}
254+
255+
if (is_resource($value)) {
256+
return 'Resource';
257+
}
258+
259+
return $value;
260+
}
110261
}

0 commit comments

Comments
 (0)