diff --git a/packages/span/composer.json b/packages/span/composer.json index fc6960791..e88d0dbcd 100644 --- a/packages/span/composer.json +++ b/packages/span/composer.json @@ -14,7 +14,7 @@ } }, "require": { - "php": ">=8.2" + "php": ">=8.3" }, "require-dev": { "swoole/ide-helper": "^5.0" diff --git a/packages/span/src/Span/Exporter/Pretty.php b/packages/span/src/Span/Exporter/Pretty.php index fd75cdc61..2661a54a7 100644 --- a/packages/span/src/Span/Exporter/Pretty.php +++ b/packages/span/src/Span/Exporter/Pretty.php @@ -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; diff --git a/packages/span/src/Span/Exporter/Sentry.php b/packages/span/src/Span/Exporter/Sentry.php index f624a56ee..162cc4b05 100644 --- a/packages/span/src/Span/Exporter/Sentry.php +++ b/packages/span/src/Span/Exporter/Sentry.php @@ -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: @@ -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; @@ -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' => [ @@ -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), ], ]; @@ -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> + */ + 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> + */ + 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', diff --git a/packages/span/tests/Exporter/SentryTest.php b/packages/span/tests/Exporter/SentryTest.php index 8694ece32..40c7f157e 100644 --- a/packages/span/tests/Exporter/SentryTest.php +++ b/packages/span/tests/Exporter/SentryTest.php @@ -10,8 +10,33 @@ use Utopia\Span\Level; use Utopia\Span\Span; +class NamespacedTestException extends \RuntimeException {} + class SentryTest extends TestCase { + /** + * Build the envelope for a span and return the decoded event payload. + * + * @return array + */ + private function buildPayload(Span $span): array + { + $exporter = new Sentry(dsn: 'https://key@sentry.io/123'); + + $method = new \ReflectionMethod(Sentry::class, 'buildEnvelope'); + $envelope = $method->invoke($exporter, $span); + + $this->assertIsString($envelope); + + $lines = explode("\n", $envelope); + $this->assertCount(3, $lines); + + $payload = json_decode($lines[2], true); + $this->assertIsArray($payload); + + return $payload; + } + public function testConstructorParsesDsn(): void { $exporter = new Sentry(dsn: 'https://publickey@sentry.io/123456'); @@ -215,4 +240,150 @@ classifier: fn(string $key): SentryField => match (true) { $this->assertTrue(true); } + + public function testEnvelopeContainsChainedExceptions(): void + { + $root = new \LogicException('root cause'); + $middle = new \InvalidArgumentException('middle', 0, $root); + $outer = new \RuntimeException('outer', 0, $middle); + + $span = new Span('test'); + $span->finish(error: $outer); + + $values = $this->buildPayload($span)['exception']['values']; + + $this->assertCount(3, $values); + + // Oldest cause first, reported exception last + $this->assertSame(\LogicException::class, $values[0]['type']); + $this->assertSame('root cause', $values[0]['value']); + $this->assertSame(\InvalidArgumentException::class, $values[1]['type']); + $this->assertSame(\RuntimeException::class, $values[2]['type']); + $this->assertSame('outer', $values[2]['value']); + + // The reported exception is the root of the mechanism tree (id 0) + $this->assertSame(0, $values[2]['mechanism']['exception_id']); + $this->assertArrayNotHasKey('parent_id', $values[2]['mechanism']); + $this->assertSame(1, $values[1]['mechanism']['exception_id']); + $this->assertSame(0, $values[1]['mechanism']['parent_id']); + $this->assertSame('__previous__', $values[1]['mechanism']['source']); + $this->assertSame(2, $values[0]['mechanism']['exception_id']); + $this->assertSame(1, $values[0]['mechanism']['parent_id']); + + foreach ($values as $value) { + $this->assertNotEmpty($value['stacktrace']['frames']); + } + } + + public function testEnvelopeSingleExceptionHasNoChainIds(): void + { + $span = new Span('test'); + $span->finish(error: new \RuntimeException('alone')); + + $values = $this->buildPayload($span)['exception']['values']; + + $this->assertCount(1, $values); + $this->assertSame('generic', $values[0]['mechanism']['type']); + $this->assertArrayNotHasKey('exception_id', $values[0]['mechanism']); + $this->assertArrayNotHasKey('parent_id', $values[0]['mechanism']); + } + + public function testEnvelopeMechanismHandledReflectsLevel(): void + { + $handledSpan = new Span('test'); + $handledSpan->finish(level: Level::Error, error: new \RuntimeException('caught')); + + $values = $this->buildPayload($handledSpan)['exception']['values']; + $this->assertTrue($values[0]['mechanism']['handled']); + + $fatalSpan = new Span('test'); + $fatalSpan->finish(level: Level::Fatal, error: new \RuntimeException('crashed')); + + $values = $this->buildPayload($fatalSpan)['exception']['values']; + $this->assertFalse($values[0]['mechanism']['handled']); + } + + public function testEnvelopeMechanismHandledAttributeOverridesLevel(): void + { + $unhandledError = new Span('test'); + $unhandledError->set('span.handled', false); + $unhandledError->finish(level: Level::Error, error: new \RuntimeException('escaped')); + + $values = $this->buildPayload($unhandledError)['exception']['values']; + $this->assertFalse($values[0]['mechanism']['handled']); + + $handledFatal = new Span('test'); + $handledFatal->set('span.handled', true); + $handledFatal->finish(level: Level::Fatal, error: new \RuntimeException('caught but fatal')); + + $values = $this->buildPayload($handledFatal)['exception']['values']; + $this->assertTrue($values[0]['mechanism']['handled']); + } + + public function testEnvelopeIncludesExceptionModule(): void + { + $span = new Span('test'); + $span->finish(error: new NamespacedTestException('namespaced')); + + $values = $this->buildPayload($span)['exception']['values']; + + $this->assertSame(NamespacedTestException::class, $values[0]['type']); + $this->assertSame(__NAMESPACE__, $values[0]['module']); + + $globalSpan = new Span('test'); + $globalSpan->finish(error: new \RuntimeException('global')); + + $values = $this->buildPayload($globalSpan)['exception']['values']; + $this->assertArrayNotHasKey('module', $values[0]); + } + + public function testEnvelopeFramesCarryFunctionNames(): void + { + $error = $this->throwAndCatch(); + + $span = new Span('test'); + $span->finish(error: $error); + + $frames = $this->buildPayload($span)['exception']['values'][0]['stacktrace']['frames']; + + $this->assertNotEmpty($frames); + + foreach ($frames as $frame) { + if (\array_key_exists('function', $frame)) { + $this->assertNotSame('', $frame['function']); + } + } + + // The throw-site frame is last and names its enclosing function + $throwSite = end($frames); + $this->assertSame(self::class . '->throwAndCatch', $throwSite['function']); + $this->assertSame(__FILE__, $throwSite['filename']); + } + + public function testEnvelopeCapsExceptionChainDepth(): void + { + $error = new \RuntimeException('level 0'); + for ($i = 1; $i < 15; $i++) { + $error = new \RuntimeException("level {$i}", 0, $error); + } + + $span = new Span('test'); + $span->finish(error: $error); + + $values = $this->buildPayload($span)['exception']['values']; + + $this->assertCount(10, $values); + // Reported exception is always kept (last); the deepest causes are dropped + $this->assertSame('level 14', $values[9]['value']); + $this->assertSame('level 5', $values[0]['value']); + } + + private function throwAndCatch(): \Throwable + { + try { + throw new \RuntimeException('thrown here'); + } catch (\RuntimeException $error) { + return $error; + } + } }