Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
2 changes: 1 addition & 1 deletion packages/span/composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
}
},
"require": {
"php": ">=8.2"
"php": ">=8.3"
},
"require-dev": {
"swoole/ide-helper": "^5.0"
Expand Down
18 changes: 9 additions & 9 deletions packages/span/src/Span/Exporter/Pretty.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,15 +13,15 @@
*/
readonly class Pretty implements Exporter
{
private const RESET = "\033[0m";
private const BOLD = "\033[1m";
private const DIM = "\033[2m";

private const RED = "\033[31m";
private const GREEN = "\033[32m";
private const YELLOW = "\033[33m";
private const CYAN = "\033[36m";
private const WHITE = "\033[37m";
private const string RESET = "\033[0m";
private const string BOLD = "\033[1m";
private const string DIM = "\033[2m";

private const string RED = "\033[31m";
private const string GREEN = "\033[32m";
private const string YELLOW = "\033[33m";
private const string CYAN = "\033[36m";
private const string WHITE = "\033[37m";

/** @var Closure(Span): bool */
private Closure $sampler;
Expand Down
153 changes: 124 additions & 29 deletions packages/span/src/Span/Exporter/Sentry.php
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,14 @@
* Only spans whose level is warning, error, or fatal are sent, at that level.
* Lower levels (info, debug) are skipped — use the Stdout exporter for those.
*
* ## Exceptions
*
* The span's error and its full getPrevious() chain (up to 10 links) are sent
* as chained exception values, so Sentry shows the root cause alongside the
* reported exception. Each value carries a mechanism whose handled flag comes
* from a boolean `span.handled` attribute when set; otherwise warning/error
* spans are marked handled and fatal spans unhandled.
*
* ## HTTP Attribute Conventions
*
* The following span attributes are mapped to Sentry's request/response structures:
Expand Down Expand Up @@ -47,7 +55,12 @@ class Sentry implements Exporter
/**
* Span levels that are exported. Anything below warning (info, debug) is skipped.
*/
private const EXPORT_LEVELS = [Level::Warn, Level::Error, Level::Fatal];
private const array EXPORT_LEVELS = [Level::Warn, Level::Error, Level::Fatal];

/**
* Maximum number of chained exceptions (via getPrevious()) sent per event.
*/
private const int MAX_CHAIN_DEPTH = 10;

private readonly string $endpoint;
private readonly string $publicKey;
Expand Down Expand Up @@ -197,28 +210,6 @@ private function buildEnvelope(Span $span): ?string
'content_type' => 'application/json',
]);

$frames = [];
foreach (array_reverse($error->getTrace()) as $frame) {
if (!isset($frame['file'])) {
continue;
}
$sentryFrame = [
'filename' => $frame['file'],
'lineno' => $frame['line'] ?? 0,
'in_app' => !str_contains($frame['file'], '/vendor/'),
];
$sentryFrame['function'] = isset($frame['class'])
? $frame['class'] . ($frame['type'] ?? '::') . $frame['function']
: $frame['function'];
$frames[] = $sentryFrame;
}

$frames[] = [
'filename' => $error->getFile(),
'lineno' => $error->getLine(),
'in_app' => !str_contains($error->getFile(), '/vendor/'),
];

$contexts = [
'trace' => $traceContext,
'runtime' => [
Expand Down Expand Up @@ -256,11 +247,7 @@ private function buildEnvelope(Span $span): ?string
'message' => $error->getMessage(),
'contexts' => $contexts,
'exception' => [
'values' => [[
'type' => $error::class,
'value' => $error->getMessage(),
'stacktrace' => ['frames' => $frames],
]],
'values' => $this->buildExceptionValues($error, $level, \is_bool($attributes['span.handled'] ?? null) ? $attributes['span.handled'] : null),
],
];

Expand Down Expand Up @@ -297,7 +284,115 @@ private function buildEnvelope(Span $span): ?string
return "{$header}\n{$itemHeader}\n{$payload}";
}

private const HANDLED_HTTP_KEYS = [
/**
* Build the Sentry exception values list, following the getPrevious() chain.
*
* Values are ordered oldest cause first (root cause at index 0, reported
* exception last), as required by the Sentry exception interface. Each value
* carries a mechanism; chained values are linked via exception_id/parent_id
* so Sentry renders the cause tree.
*
* @return array<int, array<string, mixed>>
*/
private function buildExceptionValues(\Throwable $error, Level $level, ?bool $handledOverride = null): array
{
$chain = [];
for ($current = $error; $current instanceof \Throwable && \count($chain) < self::MAX_CHAIN_DEPTH; $current = $current->getPrevious()) {
$chain[] = $current;
}

// A 'span.handled' attribute states the handling state explicitly; the
// level is only a fallback heuristic (fatal implies a process-level
// handler, anything reported at warning/error implies user code).
$handled = $handledOverride ?? ($level !== Level::Fatal);
$chained = \count($chain) > 1;
$values = [];

// $chain[0] is the reported (outermost) exception — the root of the
// mechanism tree, so it gets exception_id 0.
foreach ($chain as $id => $exception) {
$mechanism = [
'type' => 'generic',
'handled' => $handled,
];

if ($chained) {
$mechanism['exception_id'] = $id;
if ($id > 0) {
$mechanism['parent_id'] = $id - 1;
$mechanism['source'] = '__previous__';
}
}

$value = [
'type' => $exception::class,
'value' => $exception->getMessage(),
'stacktrace' => ['frames' => $this->buildFrames($exception)],
'mechanism' => $mechanism,
];

$namespaceEnd = strrpos($exception::class, '\\');
if ($namespaceEnd !== false) {
$value['module'] = substr($exception::class, 0, $namespaceEnd);
}

$values[] = $value;
}

return array_reverse($values);
}

/**
* Build Sentry stacktrace frames for one throwable, oldest call first,
* ending with the throw site.
*
* @return array<int, array<string, mixed>>
*/
private function buildFrames(\Throwable $error): array
{
$trace = $error->getTrace();

$frames = [];
foreach (array_reverse($trace) as $frame) {
if (!isset($frame['file'])) {
continue;
}
$frames[] = [
'filename' => $frame['file'],
'lineno' => $frame['line'] ?? 0,
'in_app' => !str_contains($frame['file'], '/vendor/'),
'function' => $this->formatFunction($frame),
];
}

// PHP traces describe call sites, not the throw site itself — append it.
// Its enclosing function is the callee recorded in the first trace frame.
$throwSite = [
'filename' => $error->getFile(),
'lineno' => $error->getLine(),
'in_app' => !str_contains($error->getFile(), '/vendor/'),
];

if (isset($trace[0]['function'])) {
$throwSite['function'] = $this->formatFunction($trace[0]);
}

$frames[] = $throwSite;

return $frames;
}

/**
* @param array{function: string, class?: class-string, type?: string} $frame
*/
private function formatFunction(array $frame): string
{
return isset($frame['class'])
? $frame['class'] . ($frame['type'] ?? '::') . $frame['function']
: $frame['function'];
}

private const array HANDLED_HTTP_KEYS = [
'http.url',
'http.method',
'http.query',
Expand Down
Loading
Loading