diff --git a/composer.json b/composer.json index 3ead0739..ae61915d 100644 --- a/composer.json +++ b/composer.json @@ -25,7 +25,7 @@ "require": { "php": "^7.2 | ^8.0", "illuminate/support": "^6.0 | ^7.0 | ^8.0 | ^9.0 | ^10.0 | ^11.0 | ^12.0", - "sentry/sentry": "^4.10", + "sentry/sentry": "^4.13", "symfony/psr-http-message-bridge": "^1.0 | ^2.0 | ^6.0 | ^7.0", "nyholm/psr7": "^1.0" }, diff --git a/src/Sentry/Laravel/Features/LogIntegration.php b/src/Sentry/Laravel/Features/LogIntegration.php index af4fb728..01d4ae28 100644 --- a/src/Sentry/Laravel/Features/LogIntegration.php +++ b/src/Sentry/Laravel/Features/LogIntegration.php @@ -4,6 +4,7 @@ use Illuminate\Support\Facades\Log; use Sentry\Laravel\LogChannel; +use Sentry\Laravel\Logs\LogChannel as LogsLogChannel; class LogIntegration extends Feature { @@ -17,5 +18,9 @@ public function register(): void Log::extend('sentry', function ($app, array $config) { return (new LogChannel($app))($config); }); + + Log::extend('sentry_logs', function ($app, array $config) { + return (new LogsLogChannel($app))($config); + }); } } diff --git a/src/Sentry/Laravel/Integration.php b/src/Sentry/Laravel/Integration.php index f41e8b86..cae1cc2d 100644 --- a/src/Sentry/Laravel/Integration.php +++ b/src/Sentry/Laravel/Integration.php @@ -8,6 +8,7 @@ use Sentry\EventId; use Sentry\ExceptionMechanism; use Sentry\Laravel\Integration\ModelViolations as ModelViolationReports; +use Sentry\Logs\Logs; use Sentry\SentrySdk; use Sentry\Tracing\TransactionSource; use Throwable; @@ -120,6 +121,8 @@ public static function flushEvents(): void if ($client !== null) { $client->flush(); + + Logs::getInstance()->flush(); } } diff --git a/src/Sentry/Laravel/LogChannel.php b/src/Sentry/Laravel/LogChannel.php index 30d926f6..da051ad4 100644 --- a/src/Sentry/Laravel/LogChannel.php +++ b/src/Sentry/Laravel/LogChannel.php @@ -9,11 +9,6 @@ class LogChannel extends LogManager { - /** - * @param array $config - * - * @return Logger - */ public function __invoke(array $config = []): Logger { $handler = new SentryHandler( diff --git a/src/Sentry/Laravel/Logs/LogChannel.php b/src/Sentry/Laravel/Logs/LogChannel.php new file mode 100644 index 00000000..812f2554 --- /dev/null +++ b/src/Sentry/Laravel/Logs/LogChannel.php @@ -0,0 +1,34 @@ +parseChannel($config), + [ + $this->prepareHandler($handler, $config), + ] + ); + } +} diff --git a/src/Sentry/Laravel/Logs/LogsHandler.php b/src/Sentry/Laravel/Logs/LogsHandler.php new file mode 100644 index 00000000..41fd9d13 --- /dev/null +++ b/src/Sentry/Laravel/Logs/LogsHandler.php @@ -0,0 +1,130 @@ +level; + + // filter records based on their level + $records = array_filter( + $records, + function ($record) use ($level) { + return $record['level'] >= $level; + } + ); + + if (!$records) { + return; + } + + // the record with the highest severity is the "main" one + $record = array_reduce( + $records, + function ($highest, $record) { + if ($highest === null || $record['level'] > $highest['level']) { + return $record; + } + + return $highest; + } + ); + + // the other ones are added as a context item + $logs = []; + foreach ($records as $r) { + $logs[] = $this->processRecord($r); + } + + if ($logs) { + $record['context']['logs'] = (string)$this->getBatchFormatter()->formatBatch($logs); + } + + $this->handle($record); + } + + /** + * Sets the formatter for the logs generated by handleBatch(). + * + * @param FormatterInterface $formatter + * + * @return \Sentry\Laravel\SentryHandler + */ + public function setBatchFormatter(FormatterInterface $formatter): self + { + $this->batchFormatter = $formatter; + + return $this; + } + + /** + * Gets the formatter for the logs generated by handleBatch(). + */ + public function getBatchFormatter(): FormatterInterface + { + if (!$this->batchFormatter) { + $this->batchFormatter = new LineFormatter(); + } + + return $this->batchFormatter; + } + + /** + * {@inheritdoc} + * @suppress PhanTypeMismatchArgument + */ + protected function doWrite($record): void + { + $exception = $record['context']['exception'] ?? null; + + if ($exception instanceof Throwable) { + return; + } + + \Sentry\logger()->aggregator()->add( + // This seems a little bit of a roundabout way to get the log level, but this is done for compatibility + self::getLogLevelFromSeverity( + self::getSeverityFromLevel($record['level']) + ), + $record['message'], + [], + array_merge($record['context'], $record['extra']) + ); + } + + private static function getLogLevelFromSeverity(Severity $severity): LogLevel + { + switch ($severity) { + case Severity::debug(): + return LogLevel::debug(); + case Severity::warning(): + return LogLevel::warn(); + case Severity::error(): + return LogLevel::error(); + case Severity::fatal(): + return LogLevel::fatal(); + default: + return LogLevel::info(); + } + } +} diff --git a/test/Sentry/Features/ConsoleSchedulingIntegrationTest.php b/test/Sentry/Features/ConsoleSchedulingIntegrationTest.php index 1dddde6d..d8e2fd6e 100644 --- a/test/Sentry/Features/ConsoleSchedulingIntegrationTest.php +++ b/test/Sentry/Features/ConsoleSchedulingIntegrationTest.php @@ -142,20 +142,6 @@ public function testScheduleMacroIsRegisteredWithoutDsnSet(): void $this->assertTrue(Event::hasMacro('sentryMonitor')); } - /** @define-env envSamplingAllTransactions */ - public function testScheduledCommandCreatesTransaction(): void - { - $this->getScheduler()->command('inspire')->everyMinute(); - - $this->artisan('schedule:run'); - - $this->assertSentryTransactionCount(1); - - $transaction = $this->getLastSentryEvent(); - - $this->assertEquals('inspire', $transaction->getTransaction()); - } - /** @define-env envSamplingAllTransactions */ public function testScheduledClosureCreatesTransaction(): void { diff --git a/test/Sentry/Features/LogLogsIntegrationTest.php b/test/Sentry/Features/LogLogsIntegrationTest.php new file mode 100644 index 00000000..0569d1ae --- /dev/null +++ b/test/Sentry/Features/LogLogsIntegrationTest.php @@ -0,0 +1,117 @@ +set('sentry.enable_logs', true); + + $config->set('logging.channels.sentry_logs', [ + 'driver' => 'sentry_logs', + ]); + + $config->set('logging.channels.sentry_logs_error_level', [ + 'driver' => 'sentry_logs', + 'level' => 'error', + ]); + }); + } + + public function testLogChannelIsRegistered(): void + { + $this->expectNotToPerformAssertions(); + + Log::channel('sentry_logs'); + } + + /** @define-env envWithoutDsnSet */ + public function testLogChannelIsRegisteredWithoutDsn(): void + { + $this->expectNotToPerformAssertions(); + + Log::channel('sentry_logs'); + } + + public function testLogChannelGeneratesLogs(): void + { + $logger = Log::channel('sentry_logs'); + + $logger->info('Sentry Laravel info log message'); + + $logs = $this->getAndFlushCapturedLogs(); + + $this->assertCount(1, $logs); + + $log = $logs[0]; + + $this->assertEquals(LogLevel::info(), $log->getLevel()); + $this->assertEquals('Sentry Laravel info log message', $log->getBody()); + } + + public function testLogChannelGeneratesLogsOnlyForConfiguredLevel(): void + { + $logger = Log::channel('sentry_logs_error_level'); + + $logger->info('Sentry Laravel info log message'); + $logger->warning('Sentry Laravel warning log message'); + $logger->error('Sentry Laravel error log message'); + + $logs = $this->getAndFlushCapturedLogs(); + + $this->assertCount(1, $logs); + + $log = $logs[0]; + + $this->assertEquals(LogLevel::error(), $log->getLevel()); + $this->assertEquals('Sentry Laravel error log message', $log->getBody()); + } + + public function testLogChannelDoesntCaptureExceptions(): void + { + $logger = Log::channel('sentry_logs'); + + $logger->error('Sentry Laravel error log message', ['exception' => new \Exception('Test exception')]); + + $logs = $this->getAndFlushCapturedLogs(); + + $this->assertCount(0, $logs); + } + + public function testLogChannelAddsContextAsAttributes(): void + { + $logger = Log::channel('sentry_logs'); + + $logger->info('Sentry Laravel info log message', [ + 'foo' => 'bar', + ]); + + $logs = $this->getAndFlushCapturedLogs(); + + $this->assertCount(1, $logs); + + $log = $logs[0]; + + $this->assertEquals('bar', $log->attributes()->get('foo')->getValue()); + } + + /** @return \Sentry\Logs\Log[] */ + private function getAndFlushCapturedLogs(): array + { + $logs = logger()->aggregator()->all(); + + logger()->aggregator()->flush(); + + return $logs; + } +}