From 34f7605a0362e161342716319016b1ba99e58d40 Mon Sep 17 00:00:00 2001 From: roxblnfk Date: Tue, 24 Oct 2023 18:32:42 +0400 Subject: [PATCH 1/8] HTTP Fallback handler: added an ability to catch Frames from middleware using fibers --- src/Handler/Http/Handler/Fallback.php | 46 ++++++++++++++++++++++----- 1 file changed, 38 insertions(+), 8 deletions(-) diff --git a/src/Handler/Http/Handler/Fallback.php b/src/Handler/Http/Handler/Fallback.php index f640276f..1b2b351b 100644 --- a/src/Handler/Http/Handler/Fallback.php +++ b/src/Handler/Http/Handler/Fallback.php @@ -11,6 +11,7 @@ use Buggregator\Trap\Proto\Frame; use Buggregator\Trap\Traffic\StreamClient; use DateTimeInterface; +use Nyholm\Psr7\Response; use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; @@ -35,7 +36,7 @@ public function __construct( $middlewares, /** @see Middleware::handle() */ 'handle', - static fn (): ResponseInterface => new \Nyholm\Psr7\Response(404), + static fn (): ResponseInterface => new Response(404), ResponseInterface::class, ); } @@ -44,15 +45,44 @@ public function handle(StreamClient $streamClient, ServerRequestInterface $reque { $time = $request->getAttribute('begin_at', null); $time = $time instanceof DateTimeInterface ? $time : new \DateTimeImmutable(); + $gotFrame = false; - $response = ($this->pipeline)($request); - HttpEmitter::emit($streamClient, $response); + try { + $fiber = new \Fiber(($this->pipeline)(...)); + do { + /** @var mixed $got */ + $got = $fiber->isStarted() ? $fiber->resume() : $fiber->start($request); - $streamClient->disconnect(); + if ($got instanceof Frame) { + $gotFrame = true; + yield $got; + } - yield new Frame\Http( - $request, - $time, - ); + if ($fiber->isTerminated()) { + $response = $fiber->getReturn(); + if (!$response instanceof ResponseInterface) { + throw new \RuntimeException('Invalid response type.'); + } + + break; + } + + \Fiber::suspend(); + } while (true); + + HttpEmitter::emit($streamClient, $response); + } catch (\Throwable) { + // Emit error response + HttpEmitter::emit($streamClient, new Response(500)); + } finally { + $streamClient->disconnect(); + } + + if (!$gotFrame) { + yield new Frame\Http( + $request, + $time, + ); + } } } From a9d3b74309e8799cbb92e8cbaac6a2e1f788b8ba Mon Sep 17 00:00:00 2001 From: roxblnfk Date: Tue, 24 Oct 2023 18:35:54 +0400 Subject: [PATCH 2/8] Sentry store object now is a separated Frame; added SentryTrap middleware --- src/Application.php | 1 + src/Handler/Http/Middleware/SentryTrap.php | 67 +++++++++++++++++++++ src/Proto/Frame/SentryStore.php | 66 ++++++++++++++++++++ src/ProtoType.php | 1 + src/Sender/Console/Renderer/SentryStore.php | 55 ++--------------- 5 files changed, 139 insertions(+), 51 deletions(-) create mode 100644 src/Handler/Http/Middleware/SentryTrap.php create mode 100644 src/Proto/Frame/SentryStore.php diff --git a/src/Application.php b/src/Application.php index c4237850..8ddb2154 100644 --- a/src/Application.php +++ b/src/Application.php @@ -49,6 +49,7 @@ public function __construct( new Middleware\Resources(), new Middleware\DebugPage(), new Middleware\RayRequestDump(), + new Middleware\SentryTrap(), ], [new Websocket()]), new Traffic\Dispatcher\Smtp(), new Traffic\Dispatcher\Monolog(), diff --git a/src/Handler/Http/Middleware/SentryTrap.php b/src/Handler/Http/Middleware/SentryTrap.php new file mode 100644 index 00000000..f6254d5b --- /dev/null +++ b/src/Handler/Http/Middleware/SentryTrap.php @@ -0,0 +1,67 @@ +getUri()->getPath(), '/store') + && ( + $request->getHeaderLine('X-Buggregator-Event') === 'sentry' + || $request->getAttribute('event-type') === 'sentry' + || $request->hasHeader('X-Sentry-Auth') + || $request->getUri()->getUserInfo() === 'sentry' + ) + ) { + return $this->processStore($request); + } + } catch (\JsonException) { + // Reject invalid JSON + return new Response(400); + } catch (\Throwable) { + // Reject invalid request + return new Response(400); + } + + return $next($request); + } + + private function processStore(ServerRequestInterface $request): ResponseInterface + { + $size = $request->getBody()->getSize(); + if ($size === null || $size > self::MAX_BODY_SIZE) { + // Reject too big content + return new Response(413); + } + + $payload = \json_decode((string)$request->getBody(), true, 96, \JSON_THROW_ON_ERROR); + + foreach ($payload['exception']['values'] as $exception) { + Fiber::suspend( + new Frame\SentryStore( + message: $exception, + time: $request->getAttribute('begin_at', null), + ) + ); + } + + return new Response(200); + } +} diff --git a/src/Proto/Frame/SentryStore.php b/src/Proto/Frame/SentryStore.php new file mode 100644 index 00000000..020e8a86 --- /dev/null +++ b/src/Proto/Frame/SentryStore.php @@ -0,0 +1,66 @@ +, + * exception: array + * } $message + */ + public function __construct( + public readonly array $message, + DateTimeImmutable $time = new DateTimeImmutable(), + ) { + parent::__construct(ProtoType::Sentry, $time); + } + + /** + * @throws \JsonException + */ + public function __toString(): string + { + return \json_encode($this->message, JSON_THROW_ON_ERROR); + } + + public static function fromString(string $payload, DateTimeImmutable $time): Frame + { + return new self( + \json_decode($payload, true, JSON_THROW_ON_ERROR), + $time + ); + } +} diff --git a/src/ProtoType.php b/src/ProtoType.php index 753d255f..d9aaa2dd 100644 --- a/src/ProtoType.php +++ b/src/ProtoType.php @@ -14,4 +14,5 @@ enum ProtoType: string case SMTP = 'smtp'; case Monolog = 'monolog'; case Binary = 'binary'; + case Sentry = 'sentry'; } diff --git a/src/Sender/Console/Renderer/SentryStore.php b/src/Sender/Console/Renderer/SentryStore.php index b458b880..d6e4a623 100644 --- a/src/Sender/Console/Renderer/SentryStore.php +++ b/src/Sender/Console/Renderer/SentryStore.php @@ -21,71 +21,24 @@ public function __construct( public function isSupport(Frame $frame): bool { - if ($frame->type !== ProtoType::HTTP) { - return false; - } - - \assert($frame instanceof Frame\Http); - - $request = $frame->request; - $url = \rtrim($request->getUri()->getPath(), '/'); - - return ($request->getHeaderLine('X-Buggregator-Event') === 'sentry' - || $request->getAttribute('event-type') === 'sentry' - || $request->hasHeader('X-Sentry-Auth') - || $request->getUri()->getUserInfo() === 'sentry') - && \str_ends_with($url, '/store'); + return $frame->type === ProtoType::Sentry && $frame instanceof Frame\SentryStore; } /** - * @param Frame\Http $frame + * @param Frame\SentryStore $frame * @throws \JsonException */ public function render(OutputInterface $output, Frame $frame): void { - /** - * @var array{ - * event_id: non-empty-string, - * timestamp: positive-int, - * platform: non-empty-string, - * sdk: array{ - * name: non-empty-string, - * version: non-empty-string, - * }, - * logger: non-empty-string, - * server_name: non-empty-string, - * transaction: non-empty-string, - * modules: array, - * exception: array - * } $payload - */ - $payload = \json_decode((string)$frame->request->getBody(), true, 512, \JSON_THROW_ON_ERROR); - - foreach ($payload['exception']['values'] as $exception) { - $this->renderException($exception); - } - } + $exception = $frame->message; - private function renderException(array $exception): void - { $frames = \array_reverse($exception['stacktrace']['frames']); $editorFrame = \reset($frames); $this->renderer->render( 'sentry-store', [ - 'date' => date('r'), + 'date' => $frame->time->format('Y-m-d H:i:s'), 'type' => $exception['type'], 'message' => $exception['value'], 'trace' => \iterator_to_array($this->prepareTrace($frames)), From 45bfd3acfe91620ec8f98707e3d7a6823d3cec09 Mon Sep 17 00:00:00 2001 From: roxblnfk Date: Tue, 24 Oct 2023 18:36:29 +0400 Subject: [PATCH 3/8] Add Sentry Envelope parser and Envelope Frame --- .../Middleware/SentryTrap/EnvelopeParser.php | 117 ++++++++++++++++++ src/Proto/Frame/SentryEnvelope.php | 47 +++++++ src/Proto/Frame/SentryEnvelope/Item.php | 35 ++++++ 3 files changed, 199 insertions(+) create mode 100644 src/Handler/Http/Middleware/SentryTrap/EnvelopeParser.php create mode 100644 src/Proto/Frame/SentryEnvelope.php create mode 100644 src/Proto/Frame/SentryEnvelope/Item.php diff --git a/src/Handler/Http/Middleware/SentryTrap/EnvelopeParser.php b/src/Handler/Http/Middleware/SentryTrap/EnvelopeParser.php new file mode 100644 index 00000000..5320b0cd --- /dev/null +++ b/src/Handler/Http/Middleware/SentryTrap/EnvelopeParser.php @@ -0,0 +1,117 @@ + self::MAX_ITEM_SIZE) { + throw new \RuntimeException('Item is too big.'); + } + + $itemPayload = \json_decode(self::readBytes($stream, $length), true, 512, JSON_THROW_ON_ERROR); + } else { + // Parse item payload when length is not specified + $itemPayload = \json_decode(self::readLine($stream), true, 512, JSON_THROW_ON_ERROR); + } + + return new SentryEnvelope\Item($itemHeader, $itemPayload); + } + + /** + * @param positive-int $possibleBytes Maximum number of bytes to read. If the read fragment is longer than this + * an exception will be thrown. Default is 10MB + * @throws \Throwable + */ + private static function readLine(StreamInterface $stream, int $possibleBytes = self::MAX_ITEM_SIZE): string + { + $currentPos = $stream->tell(); + $relOffset = StreamHelper::strpos($stream, "\n"); + $size = $stream->getSize(); + $offset = $relOffset === false ? $size : $currentPos + $relOffset; + + // Validate offset + $offset === null and throw new \RuntimeException('Failed to detect line end.'); + $offset - $currentPos > $possibleBytes and throw new \RuntimeException('Line is too long.'); + $offset === $currentPos and throw new \RuntimeException('End of stream.'); + + $result = self::readBytes($stream, $offset - $currentPos); + $size === $offset or $stream->seek(1, \SEEK_CUR); + + return $result; + } + + /** + * @param positive-int $length + * @return non-empty-string + * @throws \Throwable + */ + private static function readBytes(StreamInterface $stream, int $length): string + { + $currentPos = $stream->tell(); + $size = $stream->getSize(); + + $size !== null && $size - $currentPos < $length and throw new \RuntimeException('Not enough bytes to read.'); + + /** @var non-empty-string $result */ + $result = ''; + do { + $read = $stream->read($length); + $read === '' and throw new \RuntimeException('Failed to read bytes.'); + + $result .= $read; + $length -= \strlen($read); + if ($length === 0) { + break; + } + + Fiber::suspend(); + } while (true); + + return $result; + } +} diff --git a/src/Proto/Frame/SentryEnvelope.php b/src/Proto/Frame/SentryEnvelope.php new file mode 100644 index 00000000..b7a139a1 --- /dev/null +++ b/src/Proto/Frame/SentryEnvelope.php @@ -0,0 +1,47 @@ + $headers + * @param list $items + */ + public function __construct( + public readonly array $headers, + public readonly array $items, + DateTimeImmutable $time = new DateTimeImmutable(), + ) { + parent::__construct(ProtoType::Sentry, $time); + } + + /** + * @throws \JsonException + */ + public function __toString(): string + { + // todo + return \json_encode($this->headers, JSON_THROW_ON_ERROR); + } + + public static function fromString(string $payload, DateTimeImmutable $time): Frame + { + // todo + return new self( + \json_decode($payload, true, JSON_THROW_ON_ERROR), + \json_decode($payload, true, JSON_THROW_ON_ERROR), + $time + ); + } +} diff --git a/src/Proto/Frame/SentryEnvelope/Item.php b/src/Proto/Frame/SentryEnvelope/Item.php new file mode 100644 index 00000000..69bda4f2 --- /dev/null +++ b/src/Proto/Frame/SentryEnvelope/Item.php @@ -0,0 +1,35 @@ +headers, JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR) . "\n" + . \json_encode($this->payload, JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR); + } + + public function jsonSerialize(): mixed + { + return [ + 'headers' => $this->headers, + 'payload' => $this->payload, + ]; + } +} From 28cb0601ff3d9422187b17c93fae2dc97b5693d3 Mon Sep 17 00:00:00 2001 From: roxblnfk Date: Tue, 24 Oct 2023 19:10:08 +0400 Subject: [PATCH 4/8] Add fallback Sentry Envelope renderer; update Envelope Parser and use it in the SentryTrap middleware --- src/Handler/Http/Middleware/SentryTrap.php | 28 ++++++++++++ .../Middleware/SentryTrap/EnvelopeParser.php | 43 +++++++++++------- .../Console/Renderer/SentryEnvelope.php | 45 +++++++++++++++++++ src/Sender/ConsoleSender.php | 1 + 4 files changed, 102 insertions(+), 15 deletions(-) create mode 100644 src/Sender/Console/Renderer/SentryEnvelope.php diff --git a/src/Handler/Http/Middleware/SentryTrap.php b/src/Handler/Http/Middleware/SentryTrap.php index f6254d5b..744fc1cd 100644 --- a/src/Handler/Http/Middleware/SentryTrap.php +++ b/src/Handler/Http/Middleware/SentryTrap.php @@ -5,6 +5,7 @@ namespace Buggregator\Trap\Handler\Http\Middleware; use Buggregator\Trap\Handler\Http\Middleware; +use Buggregator\Trap\Handler\Http\Middleware\SentryTrap\EnvelopeParser; use Buggregator\Trap\Proto\Frame; use Fiber; use Nyholm\Psr7\Response; @@ -22,6 +23,13 @@ final class SentryTrap implements Middleware public function handle(ServerRequestInterface $request, callable $next): ResponseInterface { try { + // Detect Sentry envelope + if ($request->getHeaderLine('Content-Type') === 'application/x-sentry-envelope' + && \str_ends_with($request->getUri()->getPath(), '/envelope/') + ) { + return $this->processEnvelope($request); + } + if (\str_ends_with($request->getUri()->getPath(), '/store') && ( $request->getHeaderLine('X-Buggregator-Event') === 'sentry' @@ -43,6 +51,26 @@ public function handle(ServerRequestInterface $request, callable $next): Respons return $next($request); } + /** + * @param ServerRequestInterface $request + * @return Response + * @throws \Throwable + */ + public function processEnvelope(ServerRequestInterface $request): ResponseInterface + { + $size = $request->getBody()->getSize(); + if ($size === null || $size > self::MAX_BODY_SIZE) { + // Reject too big envelope + return new Response(413); + } + + $request->getBody()->rewind(); + $frame = EnvelopeParser::parse($request->getBody(), $request->getAttribute('begin_at', null)); + Fiber::suspend($frame); + + return new Response(200); + } + private function processStore(ServerRequestInterface $request): ResponseInterface { $size = $request->getBody()->getSize(); diff --git a/src/Handler/Http/Middleware/SentryTrap/EnvelopeParser.php b/src/Handler/Http/Middleware/SentryTrap/EnvelopeParser.php index 5320b0cd..272addae 100644 --- a/src/Handler/Http/Middleware/SentryTrap/EnvelopeParser.php +++ b/src/Handler/Http/Middleware/SentryTrap/EnvelopeParser.php @@ -16,7 +16,8 @@ */ final class EnvelopeParser { - private const MAX_ITEM_SIZE = 1024 * 1024; // 1MB + private const MAX_TEXT_ITEM_SIZE = 1024 * 1024; // 1MB + private const MAX_BINARY_ITEM_SIZE = 100 * 1024 * 1024; // 100MB public static function parse( StreamInterface $stream, @@ -30,7 +31,7 @@ public static function parse( do { try { $items[] = self::parseItem($stream); - } catch (\Throwable $e) { + } catch (\Throwable) { break; } } while (true); @@ -46,19 +47,28 @@ private static function parseItem(StreamInterface $stream): SentryEnvelope\Item // Parse item header $itemHeader = \json_decode(self::readLine($stream), true, 4, JSON_THROW_ON_ERROR); - if (isset($itemHeader['length'])) { - // Parse item payload when length is specified - $length = (int)$itemHeader['length']; - if ($length > self::MAX_ITEM_SIZE) { - throw new \RuntimeException('Item is too big.'); - } + $length = isset($itemHeader['length']) ? (int)$itemHeader['length'] : null; + $length >= 0 or throw new \RuntimeException('Invalid item length.'); + + $type = $itemHeader['type'] ?? null; - $itemPayload = \json_decode(self::readBytes($stream, $length), true, 512, JSON_THROW_ON_ERROR); - } else { - // Parse item payload when length is not specified - $itemPayload = \json_decode(self::readLine($stream), true, 512, JSON_THROW_ON_ERROR); + if ($length > ($type === 'attachment' ? self::MAX_BINARY_ITEM_SIZE : self::MAX_TEXT_ITEM_SIZE)) { + throw new \RuntimeException('Item is too big.'); } + /** @var mixed $itemPayload */ + $itemPayload = match (true) { + // Store attachments as a file stream + $type === 'attachment' => $length === null + ? StreamHelper::createFileStream()->write(self::readLine($stream)) + : StreamHelper::createFileStream()->write(self::readBytes($stream, $length)), + + // Text items + default => $length === null + ? \json_decode(self::readLine($stream), true, 512, JSON_THROW_ON_ERROR) + : \json_decode(self::readBytes($stream, $length), true, 512, JSON_THROW_ON_ERROR), + }; + return new SentryEnvelope\Item($itemHeader, $itemPayload); } @@ -67,7 +77,7 @@ private static function parseItem(StreamInterface $stream): SentryEnvelope\Item * an exception will be thrown. Default is 10MB * @throws \Throwable */ - private static function readLine(StreamInterface $stream, int $possibleBytes = self::MAX_ITEM_SIZE): string + private static function readLine(StreamInterface $stream, int $possibleBytes = self::MAX_TEXT_ITEM_SIZE): string { $currentPos = $stream->tell(); $relOffset = StreamHelper::strpos($stream, "\n"); @@ -86,12 +96,15 @@ private static function readLine(StreamInterface $stream, int $possibleBytes = s } /** - * @param positive-int $length - * @return non-empty-string + * @param int<0, max> $length * @throws \Throwable */ private static function readBytes(StreamInterface $stream, int $length): string { + if ($length === 0) { + return ''; + } + $currentPos = $stream->tell(); $size = $stream->getSize(); diff --git a/src/Sender/Console/Renderer/SentryEnvelope.php b/src/Sender/Console/Renderer/SentryEnvelope.php new file mode 100644 index 00000000..b67a3b8f --- /dev/null +++ b/src/Sender/Console/Renderer/SentryEnvelope.php @@ -0,0 +1,45 @@ +type === ProtoType::Sentry && $frame instanceof Frame\SentryEnvelope; + } + + /** + * @param Frame\SentryEnvelope $frame + * @throws \JsonException + */ + public function render(OutputInterface $output, Frame $frame): void + { + $output->writeln( + \sprintf( + '%s', + 'Sentry envelope renderer is not implemented yet.', + ) + ); + + $output->writeln( + \sprintf( + '%s', + \sprintf( + 'Envelope items count: %d', + \count($frame->items), + ), + ) + ); + } +} diff --git a/src/Sender/ConsoleSender.php b/src/Sender/ConsoleSender.php index 7ee332bd..ead5c38f 100644 --- a/src/Sender/ConsoleSender.php +++ b/src/Sender/ConsoleSender.php @@ -32,6 +32,7 @@ public static function create(OutputInterface $output): self $renderer = new ConsoleRenderer($output); $renderer->register(new Renderer\VarDumper()); $renderer->register(new Renderer\SentryStore($templateRenderer)); + $renderer->register(new Renderer\SentryEnvelope()); $renderer->register(new Renderer\Monolog($templateRenderer)); $renderer->register(new Renderer\Smtp()); $renderer->register(new Renderer\Http()); From 2ab35f6ce2348fdbd9e7555a24af764d0586a247 Mon Sep 17 00:00:00 2001 From: roxblnfk Date: Sat, 28 Oct 2023 19:42:02 +0400 Subject: [PATCH 5/8] Add sentry-store mock; fix SentryTrap middleware (Store message detection) --- .../{sentry.http => sentry-envelope.http} | Bin resources/payloads/sentry-store.http | 23 ++++++++++++++++++ src/Command/Test.php | 3 ++- src/Handler/Http/Middleware/SentryTrap.php | 3 +-- 4 files changed, 26 insertions(+), 3 deletions(-) rename resources/payloads/{sentry.http => sentry-envelope.http} (100%) create mode 100644 resources/payloads/sentry-store.http diff --git a/resources/payloads/sentry.http b/resources/payloads/sentry-envelope.http similarity index 100% rename from resources/payloads/sentry.http rename to resources/payloads/sentry-envelope.http diff --git a/resources/payloads/sentry-store.http b/resources/payloads/sentry-store.http new file mode 100644 index 00000000..5ae9306b --- /dev/null +++ b/resources/payloads/sentry-store.http @@ -0,0 +1,23 @@ +POST /api/1/store/ HTTP/1.1 +Host: 127.0.0.1:9912 +User-Agent: sentry-python/1.0 +Content-Type: application/json +X-Sentry-Auth: Sentry sentry_version=7, sentry_key=b70a31b3510c4cf793964a185cfe1fd0, sentry_secret=b7d80b520139450f903720eb7991bf3d, sentry_client=sentry-python/1.0 +Contet-Length: 335 + +{ + "event_id": "fc6d8c0c43fc4630ad850ee518f1b9d0", + "culprit": "my.module.function_name", + "timestamp": "2011-05-02T17:41:36", + "message": "SyntaxError: Wattttt!", + "exception": { + "values": [ + { + "type": "SyntaxError", + "value": "Wattttt!", + "module": "__builtins__" + } + ] + } +} + diff --git a/src/Command/Test.php b/src/Command/Test.php index 2345ee28..2fad73ae 100644 --- a/src/Command/Test.php +++ b/src/Command/Test.php @@ -39,7 +39,8 @@ protected function execute( \usleep(100_000); $this->mail($output, false); \usleep(100_000); - $this->sendContent('sentry.http'); + $this->sendContent('sentry-store.http'); + $this->sendContent('sentry-envelope.http'); \usleep(100_000); $this->sendContent('90275024.png'); diff --git a/src/Handler/Http/Middleware/SentryTrap.php b/src/Handler/Http/Middleware/SentryTrap.php index 744fc1cd..b79f1351 100644 --- a/src/Handler/Http/Middleware/SentryTrap.php +++ b/src/Handler/Http/Middleware/SentryTrap.php @@ -30,10 +30,9 @@ public function handle(ServerRequestInterface $request, callable $next): Respons return $this->processEnvelope($request); } - if (\str_ends_with($request->getUri()->getPath(), '/store') + if (\str_ends_with($request->getUri()->getPath(), '/store/') && ( $request->getHeaderLine('X-Buggregator-Event') === 'sentry' - || $request->getAttribute('event-type') === 'sentry' || $request->hasHeader('X-Sentry-Auth') || $request->getUri()->getUserInfo() === 'sentry' ) From b4a4b7b1a9fa3448e7d9648486ec82fc1825fa74 Mon Sep 17 00:00:00 2001 From: roxblnfk Date: Sun, 12 Nov 2023 00:35:51 +0400 Subject: [PATCH 6/8] Fix SentryStore renderer. Now it renders fine; add Sentry Store test payload; --- resources/payloads/sentry-store-2.http | Bin 0 -> 5496 bytes resources/payloads/sentry-store.http | 21 +- resources/templates/sentry-store.php | 52 ---- src/Command/Test.php | 5 +- src/Handler/Http/Middleware/SentryTrap.php | 14 +- src/Info.php | 2 +- src/Proto/Frame/SentryStore.php | 16 +- .../Console/Renderer/SentryEnvelope.php | 5 + src/Sender/Console/Renderer/SentryStore.php | 275 ++++++++++++++---- src/Sender/Console/Support/Common.php | 44 +++ src/Sender/ConsoleSender.php | 2 +- 11 files changed, 291 insertions(+), 145 deletions(-) create mode 100644 resources/payloads/sentry-store-2.http delete mode 100644 resources/templates/sentry-store.php diff --git a/resources/payloads/sentry-store-2.http b/resources/payloads/sentry-store-2.http new file mode 100644 index 0000000000000000000000000000000000000000..d22e7a9aa57f02b6bc4cef275e0d6159b8a132a9 GIT binary patch literal 5496 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 - - - - - - - - - -
date
type
-` -

Sentry

-

-

- - on line - : -

- - -
- - - - - -
- -
- $line): ?> - -
- . - : -
-
- -
- -
- > -
- - -
- diff --git a/src/Command/Test.php b/src/Command/Test.php index 2fad73ae..0c66ca4c 100644 --- a/src/Command/Test.php +++ b/src/Command/Test.php @@ -39,8 +39,9 @@ protected function execute( \usleep(100_000); $this->mail($output, false); \usleep(100_000); - $this->sendContent('sentry-store.http'); - $this->sendContent('sentry-envelope.http'); + $this->sendContent('sentry-store.http'); // Sentry Store very short + $this->sendContent('sentry-store-2.http'); // Sentry Store full + $this->sendContent('sentry-envelope.http'); // Sentry envelope \usleep(100_000); $this->sendContent('90275024.png'); diff --git a/src/Handler/Http/Middleware/SentryTrap.php b/src/Handler/Http/Middleware/SentryTrap.php index b79f1351..ff0ec1a7 100644 --- a/src/Handler/Http/Middleware/SentryTrap.php +++ b/src/Handler/Http/Middleware/SentryTrap.php @@ -80,14 +80,12 @@ private function processStore(ServerRequestInterface $request): ResponseInterfac $payload = \json_decode((string)$request->getBody(), true, 96, \JSON_THROW_ON_ERROR); - foreach ($payload['exception']['values'] as $exception) { - Fiber::suspend( - new Frame\SentryStore( - message: $exception, - time: $request->getAttribute('begin_at', null), - ) - ); - } + Fiber::suspend( + new Frame\SentryStore( + message: $payload, + time: $request->getAttribute('begin_at', null), + ) + ); return new Response(200); } diff --git a/src/Info.php b/src/Info.php index 7f31add4..943d0dec 100644 --- a/src/Info.php +++ b/src/Info.php @@ -10,7 +10,7 @@ class Info { public const NAME = 'Buggregator Trap'; - public const VERSION = '1.0.4'; + public const VERSION = '1.1.0'; public const LOGO_CLI_COLOR = <<, - * exception: array>, + * environment?: non-empty-string, + * server_name?: non-empty-string, + * transaction?: non-empty-string, + * modules?: array, + * exception?: array $frame->time->format('Y-m-d H:i:s.u'), + ]); $output->writeln( \sprintf( '%s', diff --git a/src/Sender/Console/Renderer/SentryStore.php b/src/Sender/Console/Renderer/SentryStore.php index d6e4a623..c95bbed9 100644 --- a/src/Sender/Console/Renderer/SentryStore.php +++ b/src/Sender/Console/Renderer/SentryStore.php @@ -7,6 +7,8 @@ use Buggregator\Trap\Proto\Frame; use Buggregator\Trap\ProtoType; use Buggregator\Trap\Sender\Console\RendererInterface; +use Buggregator\Trap\Sender\Console\Support\Common; +use DateTimeImmutable; use Symfony\Component\Console\Output\OutputInterface; /** @@ -14,11 +16,6 @@ */ final class SentryStore implements RendererInterface { - public function __construct( - private readonly TemplateRenderer $renderer, - ) { - } - public function isSupport(Frame $frame): bool { return $frame->type === ProtoType::Sentry && $frame instanceof Frame\SentryStore; @@ -30,81 +27,235 @@ public function isSupport(Frame $frame): bool */ public function render(OutputInterface $output, Frame $frame): void { - $exception = $frame->message; - - $frames = \array_reverse($exception['stacktrace']['frames']); - $editorFrame = \reset($frames); - - $this->renderer->render( - 'sentry-store', - [ - 'date' => $frame->time->format('Y-m-d H:i:s'), - 'type' => $exception['type'], - 'message' => $exception['value'], - 'trace' => \iterator_to_array($this->prepareTrace($frames)), - 'codeSnippet' => $this->renderCodeSnippet($editorFrame), - ] - ); + // Collect metadata + $meta = []; + try { + $time = new DateTimeImmutable($frame->message['timestamp']); + } catch (\Throwable) { + $time = $frame->time; + } + $meta['Time'] = $time->format('Y-m-d H:i:s.u'); + isset($frame->message['event_id']) and $meta['Event ID'] = $frame->message['event_id']; + isset($frame->message['transaction']) and $meta['Transaction'] = $frame->message['transaction']; + isset($frame->message['server_name']) and $meta['Server'] = $frame->message['server_name']; + + // Metadata from context + if (isset($frame->message['contexts']) && \is_array($frame->message['contexts'])) { + $context = $frame->message['contexts']; + isset($context['runtime']) and $meta['Runtime'] = \implode(' ', (array)$context['runtime']); + isset($context['os']) and $meta['OS'] = \implode(' ', (array)$context['os']); + } + isset($frame->message['sdk']) and $meta['SDK'] = \implode(' ', (array)$frame->message['sdk']); + + Common::renderHeader1($output, 'SENTRY'); + Common::renderMetadata($output, $meta); + + // Render short content values as tags + $tags = $this->pullTagsFromMessage($frame->message, [ + 'level' => 'level', + 'platform' => 'platform', + 'environment' => 'env', + 'logger' => 'logger', + ]); + if ($tags !== []) { + $output->writeln(''); + Common::renderTags($output, $tags); + } + + // Render tags + $tags = isset($message['tags']) && \is_array($message['tags']) ? $message['tags'] : []; + if ($tags !== []) { + Common::renderHeader2($output, 'Tags'); + Common::renderTags($output, $tags); + } + + $this->rendererExceptions($output, $frame->message['exception']['values'] ?? []); } /** - * Renders the trace of the exception. + * Collect tags from message fields + * + * @param array $message + * @param array $tags Key => Alias + * + * @return array */ - protected function prepareTrace(array $frames): \Generator + public function pullTagsFromMessage(array $message, array $tags): array { - foreach ($frames as $i => $frame) { - $file = $frame['filename']; - $line = $frame['lineno']; - $class = empty($frame['class']) ? '' : $frame['class'] . '::'; - $function = $frame['function'] ?? ''; - $pos = \str_pad((string)((int)$i + 1), 4, ' '); - - yield $pos => [ - 'file' => $file, - 'line' => $line, - 'class' => $class, - 'function' => $function, - ]; - - if ($i >= 10) { - yield $pos => '+ more ' . \count($frames) - 10 . ' frames'; - - break; + $result = []; + foreach ($tags as $key => $alias) { + if (isset($message[$key]) && \is_string($message[$key])) { + $result[$alias] ??= \implode(' ', (array)($message[$key])); + } + } + + return $result; + } + + private function rendererExceptions(OutputInterface $output, mixed $exceptions): void + { + if (!\is_array($exceptions)) { + return; + } + + $exceptions = \array_filter( + $exceptions, + static fn(mixed $exception): bool => \is_array($exception), + ); + + if (\count($exceptions) === 0) { + return; + } + + Common::renderHeader2($output, 'Exceptions'); + + foreach ($exceptions as $exception) { + // Exception type + $output->writeln(\sprintf( + '%s', + isset($exception['type']) ? $exception['type'] : 'Exception', + )); + + isset($exception['value']) and $output->writeln($exception['value']); + + $output->writeln(''); + + try { + // Stacktrace + $stacktrace = $exception['stacktrace']['frames'] ?? null; + \is_array($stacktrace) and $this->renderTrace($output, $stacktrace); + } catch (\Throwable $e) { + $output->writeln(\sprintf(' Unable to render stacktrace: %s', $e->getMessage())); } } } /** - * Renders the editor containing the code that was the - * origin of the exception. + * Renders the trace of the exception. */ - protected function renderCodeSnippet(array $frame): array + protected function renderTrace(OutputInterface $output, array $frames, bool $verbose = false): void { - $line = (int)$frame['lineno']; - $startLine = 0; - $content = ''; - if (isset($frame['pre_context'])) { - $startLine = $line - \count($frame['pre_context']) + 1; - foreach ($frame['pre_context'] as $row) { - $content .= $row . "\n"; + if ($frames === []) { + return; + } + $getValue = static fn(array $frame, string $key, ?string $default = ''): string|int|float|bool|null => + isset($frame[$key]) && \is_scalar($frame[$key]) ? $frame[$key] : $default; + + $i = \count($frames) ; + $numPad = \strlen((string)($i - 1)) + 2; + // Skipped frames + $vendorLines = []; + $isFirst = true; + + foreach (\array_reverse($frames) as $frame) { + $i--; + if (!\is_array($frame)) { + continue; + } + + $file = $getValue($frame, 'filename'); + $line = $getValue($frame, 'lineno', null); + $class = $getValue($frame, 'class'); + $class = empty($class) ? '' : $class . '::'; + $function = $getValue($frame, 'function'); + + $renderer = static fn() => $output->writeln( + \sprintf( + "%s%s%s\n%s%s%s()", + \str_pad("#$i", $numPad, ' '), + $file, + !$line ? '' : ":$line", + \str_repeat(' ', $numPad), + $class, + $function, + ) + ); + + if ($isFirst) { + $isFirst = false; + $output->writeln('Stacktrace:'); + $renderer(); + $this->renderCodeSnippet($output, $frame, padding: $numPad); + continue; + } + + if (!$verbose && \str_starts_with(\ltrim(\str_replace('\\', '/', $file), './'), 'vendor/')) { + $vendorLines[] = $renderer; + continue; + } + + if (\count($vendorLines) > 2) { + $output->writeln(\sprintf( + '%s... %d hidden vendor frames ...', + \str_repeat(' ', $numPad), + \count($vendorLines), + )); + $vendorLines = []; } + \array_map(static fn(callable $renderer) => $renderer(), $vendorLines); + $vendorLines = []; + $renderer(); } + } - if (isset($frame['context_line'])) { - $content .= $frame['context_line'] . "\n"; + /** + * Renders the code snippet around an exception. + */ + private function renderCodeSnippet(OutputInterface $output, array $frame, int $padding = 0): void + { + if (!isset($frame['context_line']) || !\is_string($frame['context_line'])) { + return; } + $minPadding = 80; + $calcPadding = static fn(string $row): int => \strlen($row) - \strlen(\ltrim($row, ' ')); + $content = []; + + try { + $startLine = (int)$frame['lineno']; + if (isset($frame['pre_context']) && \is_array($frame['pre_context'])) { + foreach ($frame['pre_context'] as $row) { + if (!\is_string($row)) { + continue; + } - if (isset($frame['post_context'])) { - foreach ($frame['post_context'] as $row) { - $content .= $row . "\n"; + $minPadding = \min($minPadding, $calcPadding($row)); + --$startLine; + $content[] = $row; + } } - } - return [ - 'file' => $frame['filename'], - 'line' => $line, - 'start_line' => $startLine, - 'content' => $content, - ]; + $content[] = $frame['context_line']; + $minPadding = \min($minPadding, $calcPadding($frame['context_line'])); + $contextLine = \array_key_last($content); + + if (isset($frame['post_context']) && \is_array($frame['post_context'])) { + foreach ($frame['post_context'] as $row) { + if (!\is_string($row)) { + continue; + } + + $minPadding = \min($minPadding, $calcPadding($row)); + $content[] = $row; + } + } + + Common::hr($output, 'white', padding: $padding); + $strPad = \strlen((string)($startLine + \count($content) - 1)); + $paddingStr = \str_repeat(' ', $padding); + foreach ($content as $line => $row) { + $output->writeln( + \sprintf( + '%s%s▕%s', + $paddingStr, + $line === $contextLine ? 'red' : 'gray', + \str_pad((string)($startLine + $line), $strPad, ' ', \STR_PAD_LEFT), + $line === $contextLine ? 'red' : 'blue', + \substr($row, $minPadding) + ) + ); + } + Common::hr($output, 'white', padding: $padding); + } catch (\Throwable) { + } } } diff --git a/src/Sender/Console/Support/Common.php b/src/Sender/Console/Support/Common.php index 7e3bbb19..ed3e00a3 100644 --- a/src/Sender/Console/Support/Common.php +++ b/src/Sender/Console/Support/Common.php @@ -67,6 +67,50 @@ public static function renderMetadata(OutputInterface $output, array $data): voi } } + /** + * @param array $tags + */ + public static function renderTags(OutputInterface $output, array $tags): void + { + if ($tags === []) { + return; + } + + $lines = []; + $parts = []; + $lineLen = 0; + foreach ($tags as $name => $value) { + if (\is_string($name)) { + $currentLen = \strlen($name) + \strlen($value) + 5; // 4 paddings and 1 margin + $tag = \sprintf(' %s: %s ', $name, $value,); + } else { + $currentLen = \strlen($value) + 3; // 2 paddings and 1 margin + $tag = \sprintf(' %s ', $value); + } + if ($lineLen === 0 || $lineLen + $currentLen < 80) { + $parts[] = $tag; + $lineLen += $currentLen; + } else { + $lines[] = \implode(' ', $parts); + $parts = [$tag]; + $lineLen = $currentLen; + } + } + $lines[] = \implode(' ', $parts); + + $output->writeln($lines); + } + + public static function hr(OutputInterface $output, string $color = 'gray', int $padding = 0): void + { + $output->writeln(\sprintf( + '%s%s', + \str_repeat(' ', $padding), + $color, + \str_repeat('─', 80 - $padding), + )); + } + /** * @param array $headers */ diff --git a/src/Sender/ConsoleSender.php b/src/Sender/ConsoleSender.php index ead5c38f..e7784b0d 100644 --- a/src/Sender/ConsoleSender.php +++ b/src/Sender/ConsoleSender.php @@ -31,7 +31,7 @@ public static function create(OutputInterface $output): self // Configure renderer $renderer = new ConsoleRenderer($output); $renderer->register(new Renderer\VarDumper()); - $renderer->register(new Renderer\SentryStore($templateRenderer)); + $renderer->register(new Renderer\SentryStore()); $renderer->register(new Renderer\SentryEnvelope()); $renderer->register(new Renderer\Monolog($templateRenderer)); $renderer->register(new Renderer\Smtp()); From b224c8010d1454a0911b558ae63c602835c17449 Mon Sep 17 00:00:00 2001 From: roxblnfk Date: Sun, 12 Nov 2023 13:18:21 +0400 Subject: [PATCH 7/8] Make Sentry frames serializable and deserializable --- src/Handler/Http/Middleware/SentryTrap.php | 2 +- .../Middleware/SentryTrap/EnvelopeParser.php | 7 ++- src/Proto/Frame.php | 2 +- src/Proto/Frame/Http.php | 2 +- src/Proto/Frame/Monolog.php | 2 +- src/Proto/Frame/Sentry.php | 27 +++++++++ .../Item.php => Sentry/EnvelopeItem.php} | 7 +-- src/Proto/Frame/Sentry/SentryEnvelope.php | 55 +++++++++++++++++++ src/Proto/Frame/{ => Sentry}/SentryStore.php | 13 ++--- src/Proto/Frame/SentryEnvelope.php | 47 ---------------- src/Proto/Frame/Smtp.php | 2 +- src/Proto/Frame/VarDumper.php | 2 +- src/Proto/Server/Version/V1.php | 1 + .../Console/Renderer/SentryEnvelope.php | 4 +- src/Sender/Console/Renderer/SentryStore.php | 4 +- 15 files changed, 106 insertions(+), 71 deletions(-) create mode 100644 src/Proto/Frame/Sentry.php rename src/Proto/Frame/{SentryEnvelope/Item.php => Sentry/EnvelopeItem.php} (61%) create mode 100644 src/Proto/Frame/Sentry/SentryEnvelope.php rename src/Proto/Frame/{ => Sentry}/SentryStore.php (85%) delete mode 100644 src/Proto/Frame/SentryEnvelope.php diff --git a/src/Handler/Http/Middleware/SentryTrap.php b/src/Handler/Http/Middleware/SentryTrap.php index ff0ec1a7..8f172db6 100644 --- a/src/Handler/Http/Middleware/SentryTrap.php +++ b/src/Handler/Http/Middleware/SentryTrap.php @@ -81,7 +81,7 @@ private function processStore(ServerRequestInterface $request): ResponseInterfac $payload = \json_decode((string)$request->getBody(), true, 96, \JSON_THROW_ON_ERROR); Fiber::suspend( - new Frame\SentryStore( + new Frame\Sentry\SentryStore( message: $payload, time: $request->getAttribute('begin_at', null), ) diff --git a/src/Handler/Http/Middleware/SentryTrap/EnvelopeParser.php b/src/Handler/Http/Middleware/SentryTrap/EnvelopeParser.php index 272addae..438e558c 100644 --- a/src/Handler/Http/Middleware/SentryTrap/EnvelopeParser.php +++ b/src/Handler/Http/Middleware/SentryTrap/EnvelopeParser.php @@ -4,7 +4,8 @@ namespace Buggregator\Trap\Handler\Http\Middleware\SentryTrap; -use Buggregator\Trap\Proto\Frame\SentryEnvelope; +use Buggregator\Trap\Proto\Frame\Sentry\EnvelopeItem; +use Buggregator\Trap\Proto\Frame\Sentry\SentryEnvelope; use Buggregator\Trap\Support\StreamHelper; use DateTimeImmutable; use Fiber; @@ -42,7 +43,7 @@ public static function parse( /** * @throws \Throwable */ - private static function parseItem(StreamInterface $stream): SentryEnvelope\Item + private static function parseItem(StreamInterface $stream): EnvelopeItem { // Parse item header $itemHeader = \json_decode(self::readLine($stream), true, 4, JSON_THROW_ON_ERROR); @@ -69,7 +70,7 @@ private static function parseItem(StreamInterface $stream): SentryEnvelope\Item : \json_decode(self::readBytes($stream, $length), true, 512, JSON_THROW_ON_ERROR), }; - return new SentryEnvelope\Item($itemHeader, $itemPayload); + return new EnvelopeItem($itemHeader, $itemPayload); } /** diff --git a/src/Proto/Frame.php b/src/Proto/Frame.php index 6b5a3e8c..1fa0b6ee 100644 --- a/src/Proto/Frame.php +++ b/src/Proto/Frame.php @@ -19,7 +19,7 @@ public function __construct( ) { } - abstract public static function fromString(string $payload, DateTimeImmutable $time): self; + abstract public static function fromString(string $payload, DateTimeImmutable $time): static; /** * @return int<0, max> diff --git a/src/Proto/Frame/Http.php b/src/Proto/Frame/Http.php index 73e72b82..fc2b9009 100644 --- a/src/Proto/Frame/Http.php +++ b/src/Proto/Frame/Http.php @@ -43,7 +43,7 @@ public function __toString(): string ], JSON_THROW_ON_ERROR); } - public static function fromString(string $payload, DateTimeImmutable $time): Frame + public static function fromString(string $payload, DateTimeImmutable $time): static { $payload = \json_decode($payload, true, \JSON_THROW_ON_ERROR); diff --git a/src/Proto/Frame/Monolog.php b/src/Proto/Frame/Monolog.php index ae9f9344..1d7b2fda 100644 --- a/src/Proto/Frame/Monolog.php +++ b/src/Proto/Frame/Monolog.php @@ -29,7 +29,7 @@ public function __toString(): string return \json_encode($this->message, JSON_THROW_ON_ERROR); } - public static function fromString(string $payload, DateTimeImmutable $time): Frame + public static function fromString(string $payload, DateTimeImmutable $time): static { return new self( \json_decode($payload, true, JSON_THROW_ON_ERROR), diff --git a/src/Proto/Frame/Sentry.php b/src/Proto/Frame/Sentry.php new file mode 100644 index 00000000..eb8247ae --- /dev/null +++ b/src/Proto/Frame/Sentry.php @@ -0,0 +1,27 @@ + SentryEnvelope::fromArray($data, $time), + $data['type'] === SentryStore::SENTRY_FRAME_TYPE => SentryStore::fromArray($data, $time), + default => throw new \InvalidArgumentException('Unknown Sentry frame type.'), + }; + } +} diff --git a/src/Proto/Frame/SentryEnvelope/Item.php b/src/Proto/Frame/Sentry/EnvelopeItem.php similarity index 61% rename from src/Proto/Frame/SentryEnvelope/Item.php rename to src/Proto/Frame/Sentry/EnvelopeItem.php index 69bda4f2..9be5c096 100644 --- a/src/Proto/Frame/SentryEnvelope/Item.php +++ b/src/Proto/Frame/Sentry/EnvelopeItem.php @@ -2,13 +2,13 @@ declare(strict_types=1); -namespace Buggregator\Trap\Proto\Frame\SentryEnvelope; +namespace Buggregator\Trap\Proto\Frame\Sentry; /** * @internal * @psalm-internal Buggregator */ -final class Item implements \Stringable, \JsonSerializable +final class EnvelopeItem implements \Stringable, \JsonSerializable { public function __construct( public readonly array $headers, @@ -21,8 +21,7 @@ public function __construct( */ public function __toString(): string { - return \json_encode($this->headers, JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR) . "\n" - . \json_encode($this->payload, JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR); + return \json_encode($this->jsonSerialize(), JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR); } public function jsonSerialize(): mixed diff --git a/src/Proto/Frame/Sentry/SentryEnvelope.php b/src/Proto/Frame/Sentry/SentryEnvelope.php new file mode 100644 index 00000000..bd89fd0f --- /dev/null +++ b/src/Proto/Frame/Sentry/SentryEnvelope.php @@ -0,0 +1,55 @@ + $headers + * @param list $items + */ + public function __construct( + public readonly array $headers, + public readonly array $items, + DateTimeImmutable $time = new DateTimeImmutable(), + ) { + parent::__construct(ProtoType::Sentry, $time); + } + + /** + * @throws \JsonException + */ + public function __toString(): string + { + return \json_encode( + ['headers' => $this->headers, 'items' => $this->items], + JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE, + ); + } + + public static function fromArray(array $data, DateTimeImmutable $time): static + { + $items = []; + foreach ($data['items'] as $item) { + $items[] = new EnvelopeItem(...$item); + } + + return new self( + $data['headers'], + $items, + $time, + ); + } +} diff --git a/src/Proto/Frame/SentryStore.php b/src/Proto/Frame/Sentry/SentryStore.php similarity index 85% rename from src/Proto/Frame/SentryStore.php rename to src/Proto/Frame/Sentry/SentryStore.php index 901570fc..67f5c23e 100644 --- a/src/Proto/Frame/SentryStore.php +++ b/src/Proto/Frame/Sentry/SentryStore.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Buggregator\Trap\Proto\Frame; +namespace Buggregator\Trap\Proto\Frame\Sentry; use Buggregator\Trap\Proto\Frame; use Buggregator\Trap\ProtoType; @@ -12,8 +12,10 @@ * @internal * @psalm-internal Buggregator */ -final class SentryStore extends Frame +final class SentryStore extends Frame\Sentry { + public const SENTRY_FRAME_TYPE = 'store'; + /** * @param array{ * event_id: non-empty-string, @@ -58,11 +60,8 @@ public function __toString(): string return \json_encode($this->message, JSON_THROW_ON_ERROR); } - public static function fromString(string $payload, DateTimeImmutable $time): Frame + public static function fromArray(array $data, DateTimeImmutable $time): static { - return new self( - \json_decode($payload, true, JSON_THROW_ON_ERROR), - $time - ); + return new self($data, $time); } } diff --git a/src/Proto/Frame/SentryEnvelope.php b/src/Proto/Frame/SentryEnvelope.php deleted file mode 100644 index b7a139a1..00000000 --- a/src/Proto/Frame/SentryEnvelope.php +++ /dev/null @@ -1,47 +0,0 @@ - $headers - * @param list $items - */ - public function __construct( - public readonly array $headers, - public readonly array $items, - DateTimeImmutable $time = new DateTimeImmutable(), - ) { - parent::__construct(ProtoType::Sentry, $time); - } - - /** - * @throws \JsonException - */ - public function __toString(): string - { - // todo - return \json_encode($this->headers, JSON_THROW_ON_ERROR); - } - - public static function fromString(string $payload, DateTimeImmutable $time): Frame - { - // todo - return new self( - \json_decode($payload, true, JSON_THROW_ON_ERROR), - \json_decode($payload, true, JSON_THROW_ON_ERROR), - $time - ); - } -} diff --git a/src/Proto/Frame/Smtp.php b/src/Proto/Frame/Smtp.php index 84ad68df..9667a44a 100644 --- a/src/Proto/Frame/Smtp.php +++ b/src/Proto/Frame/Smtp.php @@ -31,7 +31,7 @@ public function __toString(): string return \json_encode($this->message, \JSON_THROW_ON_ERROR); } - public static function fromString(string $payload, DateTimeImmutable $time): self + public static function fromString(string $payload, DateTimeImmutable $time): static { $payload = \json_decode($payload, true, \JSON_THROW_ON_ERROR); $message = Message\Smtp::fromArray($payload); diff --git a/src/Proto/Frame/VarDumper.php b/src/Proto/Frame/VarDumper.php index c594222a..0dca0a7f 100644 --- a/src/Proto/Frame/VarDumper.php +++ b/src/Proto/Frame/VarDumper.php @@ -26,7 +26,7 @@ public function __toString(): string return $this->dump; } - public static function fromString(string $payload, DateTimeImmutable $time): Frame + public static function fromString(string $payload, DateTimeImmutable $time): static { return new self($payload, $time); } diff --git a/src/Proto/Server/Version/V1.php b/src/Proto/Server/Version/V1.php index 65783ce1..348a94f2 100644 --- a/src/Proto/Server/Version/V1.php +++ b/src/Proto/Server/Version/V1.php @@ -89,6 +89,7 @@ function (array $item): Frame { ProtoType::Monolog->value => Frame\Monolog::fromString($payload, $date), ProtoType::VarDumper->value => Frame\VarDumper::fromString($payload, $date), ProtoType::HTTP->value => Frame\Http::fromString($payload, $date), + ProtoType::Sentry->value => Frame\Sentry::fromString($payload, $date), default => throw new RuntimeException('Invalid type.'), }; }, diff --git a/src/Sender/Console/Renderer/SentryEnvelope.php b/src/Sender/Console/Renderer/SentryEnvelope.php index 0489bb73..6178f4f7 100644 --- a/src/Sender/Console/Renderer/SentryEnvelope.php +++ b/src/Sender/Console/Renderer/SentryEnvelope.php @@ -17,11 +17,11 @@ final class SentryEnvelope implements RendererInterface { public function isSupport(Frame $frame): bool { - return $frame->type === ProtoType::Sentry && $frame instanceof Frame\SentryEnvelope; + return $frame->type === ProtoType::Sentry && $frame instanceof Frame\Sentry\SentryEnvelope; } /** - * @param Frame\SentryEnvelope $frame + * @param \Buggregator\Trap\Proto\Frame\Sentry\SentryEnvelope $frame * @throws \JsonException */ public function render(OutputInterface $output, Frame $frame): void diff --git a/src/Sender/Console/Renderer/SentryStore.php b/src/Sender/Console/Renderer/SentryStore.php index c95bbed9..3505228b 100644 --- a/src/Sender/Console/Renderer/SentryStore.php +++ b/src/Sender/Console/Renderer/SentryStore.php @@ -18,11 +18,11 @@ final class SentryStore implements RendererInterface { public function isSupport(Frame $frame): bool { - return $frame->type === ProtoType::Sentry && $frame instanceof Frame\SentryStore; + return $frame->type === ProtoType::Sentry && $frame instanceof Frame\Sentry\SentryStore; } /** - * @param Frame\SentryStore $frame + * @param \Buggregator\Trap\Proto\Frame\Sentry\SentryStore $frame * @throws \JsonException */ public function render(OutputInterface $output, Frame $frame): void From ec9d02f59410d2f6bb69b682764719d0a8a5e61e Mon Sep 17 00:00:00 2001 From: roxblnfk Date: Sun, 12 Nov 2023 16:17:45 +0400 Subject: [PATCH 8/8] Update Sentry Envelope rendering --- .../Console/Renderer/Sentry/Exceptions.php | 189 ++++++++++++++ src/Sender/Console/Renderer/Sentry/Header.php | 79 ++++++ .../Console/Renderer/SentryEnvelope.php | 53 ++-- src/Sender/Console/Renderer/SentryStore.php | 239 +----------------- src/Sender/Console/RendererInterface.php | 3 +- src/Sender/Console/Support/Common.php | 8 +- 6 files changed, 313 insertions(+), 258 deletions(-) create mode 100644 src/Sender/Console/Renderer/Sentry/Exceptions.php create mode 100644 src/Sender/Console/Renderer/Sentry/Header.php diff --git a/src/Sender/Console/Renderer/Sentry/Exceptions.php b/src/Sender/Console/Renderer/Sentry/Exceptions.php new file mode 100644 index 00000000..70dffc81 --- /dev/null +++ b/src/Sender/Console/Renderer/Sentry/Exceptions.php @@ -0,0 +1,189 @@ + + * + * @internal + * @psalm-internal Buggregator\Trap\Sender\Console\Renderer + */ +final class Exceptions +{ + /** + * Render Exceptions block + */ + public static function render(OutputInterface $output, mixed $exceptions): void + { + if (!\is_array($exceptions)) { + return; + } + + $exceptions = \array_filter( + $exceptions, + static fn(mixed $exception): bool => \is_array($exception), + ); + + if (\count($exceptions) === 0) { + return; + } + + Common::renderHeader2($output, 'Exceptions'); + + foreach ($exceptions as $exception) { + // Exception type + $output->writeln(\sprintf( + '%s', + isset($exception['type']) ? $exception['type'] : 'Exception', + )); + + isset($exception['value']) and $output->writeln($exception['value']); + + $output->writeln(''); + + try { + // Stacktrace + $stacktrace = $exception['stacktrace']['frames'] ?? null; + \is_array($stacktrace) and self::renderTrace($output, $stacktrace); + } catch (\Throwable $e) { + $output->writeln(\sprintf(' Unable to render stacktrace: %s', $e->getMessage())); + } + } + } + + /** + * Renders the trace of the exception. + */ + private static function renderTrace(OutputInterface $output, array $frames, bool $verbose = false): void + { + if ($frames === []) { + return; + } + $getValue = static fn(array $frame, string $key, ?string $default = ''): string|int|float|bool|null => + isset($frame[$key]) && \is_scalar($frame[$key]) ? $frame[$key] : $default; + + $i = \count($frames) ; + $numPad = \strlen((string)($i - 1)) + 2; + // Skipped frames + $vendorLines = []; + $isFirst = true; + + foreach (\array_reverse($frames) as $frame) { + $i--; + if (!\is_array($frame)) { + continue; + } + + $file = $getValue($frame, 'filename'); + $line = $getValue($frame, 'lineno', null); + $class = $getValue($frame, 'class'); + $class = empty($class) ? '' : $class . '::'; + $function = $getValue($frame, 'function'); + + $renderer = static fn() => $output->writeln( + \sprintf( + "%s%s%s\n%s%s%s()", + \str_pad("#$i", $numPad, ' '), + $file, + !$line ? '' : ":$line", + \str_repeat(' ', $numPad), + $class, + $function, + ) + ); + + if ($isFirst) { + $isFirst = false; + $output->writeln('Stacktrace:'); + $renderer(); + self::renderCodeSnippet($output, $frame, padding: $numPad); + continue; + } + + if (!$verbose && \str_starts_with(\ltrim(\str_replace('\\', '/', $file), './'), 'vendor/')) { + $vendorLines[] = $renderer; + continue; + } + + if (\count($vendorLines) > 2) { + $output->writeln(\sprintf( + '%s... %d hidden vendor frames ...', + \str_repeat(' ', $numPad), + \count($vendorLines), + )); + $vendorLines = []; + } + \array_map(static fn(callable $renderer) => $renderer(), $vendorLines); + $vendorLines = []; + $renderer(); + } + } + + /** + * Renders the code snippet around an exception. + */ + private static function renderCodeSnippet(OutputInterface $output, array $frame, int $padding = 0): void + { + if (!isset($frame['context_line']) || !\is_string($frame['context_line'])) { + return; + } + $minPadding = 80; + $calcPadding = static fn(string $row): int => \strlen($row) - \strlen(\ltrim($row, ' ')); + $content = []; + + try { + $startLine = (int)$frame['lineno']; + if (isset($frame['pre_context']) && \is_array($frame['pre_context'])) { + foreach ($frame['pre_context'] as $row) { + if (!\is_string($row)) { + continue; + } + + $minPadding = \min($minPadding, $calcPadding($row)); + --$startLine; + $content[] = $row; + } + } + + $content[] = $frame['context_line']; + $minPadding = \min($minPadding, $calcPadding($frame['context_line'])); + $contextLine = \array_key_last($content); + + if (isset($frame['post_context']) && \is_array($frame['post_context'])) { + foreach ($frame['post_context'] as $row) { + if (!\is_string($row)) { + continue; + } + + $minPadding = \min($minPadding, $calcPadding($row)); + $content[] = $row; + } + } + + Common::hr($output, 'white', padding: $padding); + $strPad = \strlen((string)($startLine + \count($content) - 1)); + $paddingStr = \str_repeat(' ', $padding); + foreach ($content as $line => $row) { + $output->writeln( + \sprintf( + '%s%s▕%s', + $paddingStr, + $line === $contextLine ? 'red' : 'gray', + \str_pad((string)($startLine + $line), $strPad, ' ', \STR_PAD_LEFT), + $line === $contextLine ? 'red' : 'blue', + \substr($row, $minPadding) + ) + ); + } + Common::hr($output, 'white', padding: $padding); + } catch (\Throwable) { + } + } +} diff --git a/src/Sender/Console/Renderer/Sentry/Header.php b/src/Sender/Console/Renderer/Sentry/Header.php new file mode 100644 index 00000000..b207f16c --- /dev/null +++ b/src/Sender/Console/Renderer/Sentry/Header.php @@ -0,0 +1,79 @@ + + * + * @internal + */ +final class Header +{ + public static function renderMessageHeader(OutputInterface $output, array $message) + { + // Collect metadata + $meta = []; + $time = new DateTimeImmutable(isset($message['sent_at']) ? $message['sent_at'] : "@$message[timestamp]"); + $meta['Time'] = $time->format('Y-m-d H:i:s.u'); + isset($message['event_id']) and $meta['Event ID'] = $message['event_id']; + isset($message['transaction']) and $meta['Transaction'] = $message['transaction']; + isset($message['server_name']) and $meta['Server'] = $message['server_name']; + + // Metadata from context + if (isset($message['contexts']) && \is_array($message['contexts'])) { + $context = $message['contexts']; + isset($context['runtime']) and $meta['Runtime'] = \implode(' ', (array)$context['runtime']); + isset($context['os']) and $meta['OS'] = \implode(' ', (array)$context['os']); + } + isset($message['sdk']) and $meta['SDK'] = \implode(' ', (array)$message['sdk']); + + Common::renderMetadata($output, $meta); + + // Render short content values as tags + $tags = self::pullTagsFromMessage($message, [ + 'level' => 'level', + 'platform' => 'platform', + 'environment' => 'env', + 'logger' => 'logger', + ]); + if ($tags !== []) { + $output->writeln(''); + Common::renderTags($output, $tags); + } + + // Render tags + $tags = isset($message['tags']) && \is_array($message['tags']) ? $message['tags'] : []; + if ($tags !== []) { + Common::renderHeader2($output, 'Tags'); + Common::renderTags($output, $tags); + } + } + + /** + * Collect tags from message fields + * + * @param array $message + * @param array $tags Key => Alias + * + * @return array + */ + private static function pullTagsFromMessage(array $message, array $tags): array + { + $result = []; + foreach ($tags as $key => $alias) { + if (isset($message[$key]) && \is_string($message[$key])) { + $result[$alias] ??= \implode(' ', (array)($message[$key])); + } + } + + return $result; + } +} diff --git a/src/Sender/Console/Renderer/SentryEnvelope.php b/src/Sender/Console/Renderer/SentryEnvelope.php index 6178f4f7..b34736d0 100644 --- a/src/Sender/Console/Renderer/SentryEnvelope.php +++ b/src/Sender/Console/Renderer/SentryEnvelope.php @@ -6,11 +6,15 @@ use Buggregator\Trap\Proto\Frame; use Buggregator\Trap\ProtoType; +use Buggregator\Trap\Sender\Console\Renderer\Sentry\Exceptions; +use Buggregator\Trap\Sender\Console\Renderer\Sentry\Header; use Buggregator\Trap\Sender\Console\RendererInterface; use Buggregator\Trap\Sender\Console\Support\Common; use Symfony\Component\Console\Output\OutputInterface; /** + * @implements RendererInterface + * * @internal */ final class SentryEnvelope implements RendererInterface @@ -20,31 +24,34 @@ public function isSupport(Frame $frame): bool return $frame->type === ProtoType::Sentry && $frame instanceof Frame\Sentry\SentryEnvelope; } - /** - * @param \Buggregator\Trap\Proto\Frame\Sentry\SentryEnvelope $frame - * @throws \JsonException - */ public function render(OutputInterface $output, Frame $frame): void { Common::renderHeader1($output, 'SENTRY', 'ENVELOPE'); - Common::renderMetadata($output, [ - 'Time' => $frame->time->format('Y-m-d H:i:s.u'), - ]); - $output->writeln( - \sprintf( - '%s', - 'Sentry envelope renderer is not implemented yet.', - ) - ); - - $output->writeln( - \sprintf( - '%s', - \sprintf( - 'Envelope items count: %d', - \count($frame->items), - ), - ) - ); + Header::renderMessageHeader($output, $frame->headers + ['timestamp' => $frame->time->format('U.u')]); + + $i = 0; + foreach ($frame->items as $item) { + ++$i; + try { + $type = $item->headers['type'] ?? null; + Common::renderHeader2($output, "Item $i", green: $type); + + Header::renderMessageHeader($output, $item->payload); + $this->renderItem($output, $item); + } catch (\Throwable $e) { + $output->writeln(['Render error', $e->getMessage()]); + \trap($e); + } + } + } + + private function renderItem(OutputInterface $output, Frame\Sentry\EnvelopeItem $data): void + { + if (isset($data->payload['exceptions'])) { + Exceptions::render($output, $data->payload['exceptions']); + return; + } + + $output->writeln(['', 'There is no renderer for this item type.']); } } diff --git a/src/Sender/Console/Renderer/SentryStore.php b/src/Sender/Console/Renderer/SentryStore.php index 3505228b..43f51143 100644 --- a/src/Sender/Console/Renderer/SentryStore.php +++ b/src/Sender/Console/Renderer/SentryStore.php @@ -6,12 +6,15 @@ use Buggregator\Trap\Proto\Frame; use Buggregator\Trap\ProtoType; +use Buggregator\Trap\Sender\Console\Renderer\Sentry\Exceptions; +use Buggregator\Trap\Sender\Console\Renderer\Sentry\Header; use Buggregator\Trap\Sender\Console\RendererInterface; use Buggregator\Trap\Sender\Console\Support\Common; -use DateTimeImmutable; use Symfony\Component\Console\Output\OutputInterface; /** + * @implements RendererInterface + * * @internal */ final class SentryStore implements RendererInterface @@ -21,241 +24,15 @@ public function isSupport(Frame $frame): bool return $frame->type === ProtoType::Sentry && $frame instanceof Frame\Sentry\SentryStore; } - /** - * @param \Buggregator\Trap\Proto\Frame\Sentry\SentryStore $frame - * @throws \JsonException - */ public function render(OutputInterface $output, Frame $frame): void { - // Collect metadata - $meta = []; - try { - $time = new DateTimeImmutable($frame->message['timestamp']); - } catch (\Throwable) { - $time = $frame->time; - } - $meta['Time'] = $time->format('Y-m-d H:i:s.u'); - isset($frame->message['event_id']) and $meta['Event ID'] = $frame->message['event_id']; - isset($frame->message['transaction']) and $meta['Transaction'] = $frame->message['transaction']; - isset($frame->message['server_name']) and $meta['Server'] = $frame->message['server_name']; - - // Metadata from context - if (isset($frame->message['contexts']) && \is_array($frame->message['contexts'])) { - $context = $frame->message['contexts']; - isset($context['runtime']) and $meta['Runtime'] = \implode(' ', (array)$context['runtime']); - isset($context['os']) and $meta['OS'] = \implode(' ', (array)$context['os']); - } - isset($frame->message['sdk']) and $meta['SDK'] = \implode(' ', (array)$frame->message['sdk']); - Common::renderHeader1($output, 'SENTRY'); - Common::renderMetadata($output, $meta); - - // Render short content values as tags - $tags = $this->pullTagsFromMessage($frame->message, [ - 'level' => 'level', - 'platform' => 'platform', - 'environment' => 'env', - 'logger' => 'logger', - ]); - if ($tags !== []) { - $output->writeln(''); - Common::renderTags($output, $tags); - } - - // Render tags - $tags = isset($message['tags']) && \is_array($message['tags']) ? $message['tags'] : []; - if ($tags !== []) { - Common::renderHeader2($output, 'Tags'); - Common::renderTags($output, $tags); - } - - $this->rendererExceptions($output, $frame->message['exception']['values'] ?? []); - } - - /** - * Collect tags from message fields - * - * @param array $message - * @param array $tags Key => Alias - * - * @return array - */ - public function pullTagsFromMessage(array $message, array $tags): array - { - $result = []; - foreach ($tags as $key => $alias) { - if (isset($message[$key]) && \is_string($message[$key])) { - $result[$alias] ??= \implode(' ', (array)($message[$key])); - } - } - - return $result; - } - - private function rendererExceptions(OutputInterface $output, mixed $exceptions): void - { - if (!\is_array($exceptions)) { - return; - } - - $exceptions = \array_filter( - $exceptions, - static fn(mixed $exception): bool => \is_array($exception), - ); - - if (\count($exceptions) === 0) { - return; - } - - Common::renderHeader2($output, 'Exceptions'); - - foreach ($exceptions as $exception) { - // Exception type - $output->writeln(\sprintf( - '%s', - isset($exception['type']) ? $exception['type'] : 'Exception', - )); - - isset($exception['value']) and $output->writeln($exception['value']); - - $output->writeln(''); - - try { - // Stacktrace - $stacktrace = $exception['stacktrace']['frames'] ?? null; - \is_array($stacktrace) and $this->renderTrace($output, $stacktrace); - } catch (\Throwable $e) { - $output->writeln(\sprintf(' Unable to render stacktrace: %s', $e->getMessage())); - } - } - } - - /** - * Renders the trace of the exception. - */ - protected function renderTrace(OutputInterface $output, array $frames, bool $verbose = false): void - { - if ($frames === []) { - return; - } - $getValue = static fn(array $frame, string $key, ?string $default = ''): string|int|float|bool|null => - isset($frame[$key]) && \is_scalar($frame[$key]) ? $frame[$key] : $default; - - $i = \count($frames) ; - $numPad = \strlen((string)($i - 1)) + 2; - // Skipped frames - $vendorLines = []; - $isFirst = true; - - foreach (\array_reverse($frames) as $frame) { - $i--; - if (!\is_array($frame)) { - continue; - } - - $file = $getValue($frame, 'filename'); - $line = $getValue($frame, 'lineno', null); - $class = $getValue($frame, 'class'); - $class = empty($class) ? '' : $class . '::'; - $function = $getValue($frame, 'function'); - - $renderer = static fn() => $output->writeln( - \sprintf( - "%s%s%s\n%s%s%s()", - \str_pad("#$i", $numPad, ' '), - $file, - !$line ? '' : ":$line", - \str_repeat(' ', $numPad), - $class, - $function, - ) - ); - - if ($isFirst) { - $isFirst = false; - $output->writeln('Stacktrace:'); - $renderer(); - $this->renderCodeSnippet($output, $frame, padding: $numPad); - continue; - } - - if (!$verbose && \str_starts_with(\ltrim(\str_replace('\\', '/', $file), './'), 'vendor/')) { - $vendorLines[] = $renderer; - continue; - } - - if (\count($vendorLines) > 2) { - $output->writeln(\sprintf( - '%s... %d hidden vendor frames ...', - \str_repeat(' ', $numPad), - \count($vendorLines), - )); - $vendorLines = []; - } - \array_map(static fn(callable $renderer) => $renderer(), $vendorLines); - $vendorLines = []; - $renderer(); - } - } - - /** - * Renders the code snippet around an exception. - */ - private function renderCodeSnippet(OutputInterface $output, array $frame, int $padding = 0): void - { - if (!isset($frame['context_line']) || !\is_string($frame['context_line'])) { - return; - } - $minPadding = 80; - $calcPadding = static fn(string $row): int => \strlen($row) - \strlen(\ltrim($row, ' ')); - $content = []; try { - $startLine = (int)$frame['lineno']; - if (isset($frame['pre_context']) && \is_array($frame['pre_context'])) { - foreach ($frame['pre_context'] as $row) { - if (!\is_string($row)) { - continue; - } - - $minPadding = \min($minPadding, $calcPadding($row)); - --$startLine; - $content[] = $row; - } - } - - $content[] = $frame['context_line']; - $minPadding = \min($minPadding, $calcPadding($frame['context_line'])); - $contextLine = \array_key_last($content); - - if (isset($frame['post_context']) && \is_array($frame['post_context'])) { - foreach ($frame['post_context'] as $row) { - if (!\is_string($row)) { - continue; - } - - $minPadding = \min($minPadding, $calcPadding($row)); - $content[] = $row; - } - } - - Common::hr($output, 'white', padding: $padding); - $strPad = \strlen((string)($startLine + \count($content) - 1)); - $paddingStr = \str_repeat(' ', $padding); - foreach ($content as $line => $row) { - $output->writeln( - \sprintf( - '%s%s▕%s', - $paddingStr, - $line === $contextLine ? 'red' : 'gray', - \str_pad((string)($startLine + $line), $strPad, ' ', \STR_PAD_LEFT), - $line === $contextLine ? 'red' : 'blue', - \substr($row, $minPadding) - ) - ); - } - Common::hr($output, 'white', padding: $padding); - } catch (\Throwable) { + Header::renderMessageHeader($output, $frame->message + ['timestamp' => $frame->time->format('U.u')]); + Exceptions::render($output, $frame->message['exception']['values'] ?? []); + } catch (\Throwable $e) { + $output->writeln(['Render error', $e->getMessage()]); } } } diff --git a/src/Sender/Console/RendererInterface.php b/src/Sender/Console/RendererInterface.php index 0951e81f..c9937263 100644 --- a/src/Sender/Console/RendererInterface.php +++ b/src/Sender/Console/RendererInterface.php @@ -8,8 +8,7 @@ use Symfony\Component\Console\Output\OutputInterface; /** - * @template TFrame of Frame - * @template-covariant + * @template-covariant TFrame of Frame */ interface RendererInterface { diff --git a/src/Sender/Console/Support/Common.php b/src/Sender/Console/Support/Common.php index ed3e00a3..0eb9d628 100644 --- a/src/Sender/Console/Support/Common.php +++ b/src/Sender/Console/Support/Common.php @@ -12,10 +12,13 @@ */ final class Common { - public static function renderHeader1(OutputInterface $output, string $title, string ...$sub): void + public static function renderHeader1(OutputInterface $output, string $title, ?string ...$sub): void { $parts = [" $title "]; foreach ($sub as $color => $value) { + if ($value === null) { + continue; + } $parts[] = \sprintf(' %s ', \is_string($color) ? $color : 'gray', $value); } @@ -26,7 +29,8 @@ public static function renderHeader2(OutputInterface $output, string $title, str { $parts = ["# $title "]; foreach ($sub as $color => $value) { - $parts[] = \sprintf(' %s ', $value); + $color = \is_string($color) ? $color : 'gray'; + $parts[] = \sprintf(' %s ', $color, $value); } $output->writeln(['', \implode('', $parts), '']);