Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 62 additions & 6 deletions src/EventPayloadBuilder.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
Expand Down Expand Up @@ -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);
}
}

Expand All @@ -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,
]);
}
Expand Down Expand Up @@ -222,19 +231,66 @@ private function sanitizeArrayKeys($value)
return $sanitized;
}

/**
* Build Hawk `arguments` (string[]) from a frame: prefers ready `arguments`, else formats raw `args`.
*
* @param array $frame
*
* @return array
*/
private function buildArgumentsList(array $frame): array
{
$max = StacktraceFrameBuilder::MAX_FRAME_ARGUMENTS;
$maxBytes = StacktraceFrameBuilder::MAX_ARGUMENT_LINE_BYTES;

if (isset($frame['arguments']) && is_array($frame['arguments'])) {
$out = [];
foreach (array_slice($frame['arguments'], 0, $max) as $line) {
$out[] = $this->truncateArgumentLineString((string) $line, $maxBytes);
}

return $out;
}

if (!empty($frame['args']) && is_array($frame['args'])) {
$out = [];
foreach (array_slice($this->stacktraceFrameBuilder->getFormattedArguments($frame), 0, $max) as $line) {
$out[] = $this->truncateArgumentLineString((string) $line, $maxBytes);
}

return $out;
}

return [];
}

private function truncateArgumentLineString(string $line, int $maxBytes): string
Comment thread
neSpecc marked this conversation as resolved.
Outdated
{
if (strlen($line) <= $maxBytes) {
return $line;
}

return substr($line, 0, $maxBytes - 3) . '...';

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Trade-off: Serialized argument values are intentionally not byte-capped so lines stay full valid JSON for tooling; size is mitigated by argument count, depth, and circular markers; stricter limits belong at the API/transport layer or an optional SDK setting.

}

/**
* 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);
}
Comment on lines 295 to 306

Copilot AI Apr 30, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

transformForJson() only enforces a max depth; it does not detect circular/self-referential arrays. For values like $GLOBALS (or any array with a self-reference), this will repeatedly re-walk the same structure until the depth limit and can still produce a very large nested payload and high CPU. Consider adding circular reference detection (similar to Serializer::prepare()), returning a sentinel like [circular] when an ancestor is encountered.

Copilot uses AI. Check for mistakes.

return $result;
Expand Down
54 changes: 48 additions & 6 deletions src/Serializer.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,12 @@
*/
final class Serializer
{
/**
* Long scalar strings: insert U+200B every N chars so UIs can wrap (like soft word-break),
* without breaking JSON validity. Does not alter tokens like short keys.
*/
private const SOFT_BREAK_EVERY_CHARS = 72;

/**
* Process any value and makes it safe (in appropriate format) to send to hawk
*
Expand All @@ -20,7 +26,8 @@ final class Serializer
*/
public function serializeValue($value): string
{
$encoded = json_encode($this->prepare($value), JSON_UNESCAPED_UNICODE);
$flags = JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT;
$encoded = json_encode($this->prepare($value, 0), $flags);

if ($encoded === false) {
return '';
Expand All @@ -29,29 +36,39 @@ 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
*
* @return array|mixed|string
*/
private function prepare($value)
private function prepare($value, int $depth = 0)
{
if ($depth > self::PREPARE_MAX_DEPTH) {
return '[max depth]';
}

if (!is_object($value) && (is_array($value) || is_iterable($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);
} else {
$result[$key] = $this->transform($subValue);
}
}

return $result;
} else {
return $this->transform($value);
}

return $this->transform($value);
}

/**
Expand All @@ -71,11 +88,36 @@ private function transform($value)
return get_class($value);
} elseif (is_resource($value)) {
return 'Resource';
} elseif (is_string($value)) {
return $this->insertSoftBreaksInString($value);
} else {
return $value;
}
}

/**
* Insert zero-width spaces for long strings so Hawk (or any monospace view) can wrap
* without CSS word-break; JSON remains valid after json_encode.
*/
private function insertSoftBreaksInString(string $value): string
Comment thread
neSpecc marked this conversation as resolved.
Outdated
{
$len = strlen($value);
if ($len <= self::SOFT_BREAK_EVERY_CHARS) {
return $value;
}

$chunk = self::SOFT_BREAK_EVERY_CHARS;
$zwsp = "\u{200B}";

if (function_exists('mb_str_split')) {
$parts = mb_str_split($value, $chunk, 'UTF-8');

return implode($zwsp, $parts);
}

return implode($zwsp, str_split($value, $chunk));
}

/**
* Check array if it is associative
*
Expand Down
44 changes: 41 additions & 3 deletions src/StacktraceFrameBuilder.php
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,16 @@
*/
final class StacktraceFrameBuilder
{
/**
* Max function arguments to include per frame (payload size, CPU, Hawk limits).
*/
public const MAX_FRAME_ARGUMENTS = 20;

/**
* Max length of one serialized "name = value" line (bytes; avoids huge JSON in events).
*/
public const MAX_ARGUMENT_LINE_BYTES = 2048;

/**
* @var Serializer
*/
Expand Down Expand Up @@ -183,6 +193,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
*
Expand Down Expand Up @@ -216,6 +239,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 {
Expand All @@ -231,6 +257,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];
}
Expand All @@ -246,15 +276,23 @@ private function getArgs(array $frame): array
$value = $this->serializer->serializeValue($value);

try {
$newArguments[] = sprintf('%s = %s', $name, $value);
$line = sprintf('%s = %s', $name, $value);
$newArguments[] = $this->truncateArgumentLine($line);
} catch (\Exception $e) {
// Ignore unknown types
}
}

$arguments = $newArguments;
return $newArguments;
}

private function truncateArgumentLine(string $line): string
{
if (strlen($line) <= self::MAX_ARGUMENT_LINE_BYTES) {
return $line;
Comment on lines +296 to +303

Copilot AI Apr 30, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

truncatePrebuiltArgumentLine() returns lines without the " = " delimiter unchanged (no length cap). That allows a single prebuilt argument entry to be extremely large and bypass any payload-size protections. Consider applying the same max-byte truncation to the whole line even when the delimiter is missing.

Suggested change
* 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;
* Lines without `" = "` are truncated as whole lines to the configured byte cap.
*/
public static function truncatePrebuiltArgumentLine(string $line): string
{
$separator = ' = ';
$position = strpos($line, $separator);
if ($position === false) {
return self::truncateUtf8StringToMaxBytes($line, self::MAX_ARGUMENT_NAME_BYTES);

Copilot uses AI. Check for mistakes.
}

return $arguments;
return substr($line, 0, self::MAX_ARGUMENT_LINE_BYTES - 3) . '...';
}

/**
Expand Down
Loading
Loading