diff --git a/src/Log/Engine/SentryLog.php b/src/Log/Engine/SentryLog.php new file mode 100644 index 0000000..8947402 --- /dev/null +++ b/src/Log/Engine/SentryLog.php @@ -0,0 +1,64 @@ +logsWillBeFlushed = true; + EventManager::instance()->on('Server.terminate', function (): void { + Logs::getInstance()->flush(); + }); + } + } + + /** + * @param string $level + * @param \Stringable|string $message + * @param array $context + * @return void + * @phpcsSuppress SlevomatCodingStandard.TypeHints.ParameterTypeHint.MissingNativeTypeHint + * @psalm-suppress MoreSpecificImplementedParamType + */ + public function log($level, string|Stringable $message, array $context = []): void + { + $message = $this->interpolate($message, $context); + $message = $this->formatter->format($level, $message, $context); + + $sentryLogger = Logs::getInstance(); + + match ($level) { + LogLevel::EMERGENCY, LogLevel::ALERT, LogLevel::CRITICAL => $sentryLogger->fatal($message, [], $context), + LogLevel::ERROR => $sentryLogger->error($message), + LogLevel::WARNING => $sentryLogger->warn($message, [], $context), + LogLevel::NOTICE, LogLevel::INFO => $sentryLogger->info($message, [], $context), + LogLevel::DEBUG => $sentryLogger->debug($message, [], $context), + default => $sentryLogger->trace($message, [], $context), + }; + + if (!$this->logsWillBeFlushed) { + $sentryLogger->flush(); + } + } +} + +// phpcs:disable +class_alias('CakeSentry\Log\Engine\SentryLog', 'CakeSentry\Log\Engines\SentryLog'); +// phpcs:enable diff --git a/src/Log/Engines/SentryLog.php b/src/Log/Engines/SentryLog.php index c9511c2..198f4b2 100644 --- a/src/Log/Engines/SentryLog.php +++ b/src/Log/Engines/SentryLog.php @@ -3,58 +3,10 @@ namespace CakeSentry\Log\Engines; -use Cake\Event\EventManager; -use Cake\Log\Engine\BaseLog; -use Psr\Log\LogLevel; -use Sentry\Logs\Logs; -use Stringable; +use CakeSentry\Log\Engine\SentryLog; +use function Cake\Core\deprecationWarning; -class SentryLog extends BaseLog -{ - public bool $logsWillBeFlushed = false; +$msg = 'Use `CakeSentry\Log\Engine\SentryLog` instead of `CakeSentry\Log\Engines\SentryLog`.'; +deprecationWarning('3.5.3', $msg); - /** - * @param array $config - */ - public function __construct(array $config = []) - { - parent::__construct($config); - - // Send the logs to sentry after the client has received the response - if (PHP_SAPI !== 'cli' && function_exists('fastcgi_finish_request')) { - $this->logsWillBeFlushed = true; - EventManager::instance()->on('Server.terminate', function (): void { - Logs::getInstance()->flush(); - }); - } - } - - /** - * @param string $level - * @param \Stringable|string $message - * @param array $context - * @return void - * @phpcsSuppress SlevomatCodingStandard.TypeHints.ParameterTypeHint.MissingNativeTypeHint - * @psalm-suppress MoreSpecificImplementedParamType - */ - public function log($level, string|Stringable $message, array $context = []): void - { - $message = $this->interpolate($message, $context); - $message = $this->formatter->format($level, $message, $context); - - $sentryLogger = Logs::getInstance(); - - match ($level) { - LogLevel::EMERGENCY, LogLevel::ALERT, LogLevel::CRITICAL => $sentryLogger->fatal($message, [], $context), - LogLevel::ERROR => $sentryLogger->error($message), - LogLevel::WARNING => $sentryLogger->warn($message, [], $context), - LogLevel::NOTICE, LogLevel::INFO => $sentryLogger->info($message, [], $context), - LogLevel::DEBUG => $sentryLogger->debug($message, [], $context), - default => $sentryLogger->trace($message, [], $context), - }; - - if (!$this->logsWillBeFlushed) { - $sentryLogger->flush(); - } - } -} +class_exists(SentryLog::class); diff --git a/tests/TestCase/Log/Engine/SentryLogTest.php b/tests/TestCase/Log/Engine/SentryLogTest.php new file mode 100644 index 0000000..b87fb75 --- /dev/null +++ b/tests/TestCase/Log/Engine/SentryLogTest.php @@ -0,0 +1,160 @@ +skipIf(!method_exists('Sentry\Logs\Log', 'getPsrLevel'), 'Sentry SDK too low'); + + $this->originalHub = SentrySdk::getCurrentHub(); + } + + public function tearDown(): void + { + SentrySdk::setCurrentHub($this->originalHub); + + parent::tearDown(); + } + + #[DataProvider('logLevelProvider')] + public function testLogSendsFormattedLogsToSentry( + string $level, + string $expectedPsrLevel, + bool $expectsContextAttributes, + ): void { + $client = $this->createClientMock(function (Event $event) use ($level, $expectedPsrLevel, $expectsContextAttributes): void { + $logs = $event->getLogs(); + + $this->assertCount(1, $logs); + $this->assertSame(sprintf('%s: Message 42', $level), $logs[0]->getBody()); + $this->assertSame($expectedPsrLevel, $logs[0]->getPsrLevel()); + + $attributes = $logs[0]->attributes()->toSimpleArray(); + if ($expectsContextAttributes) { + $this->assertSame(42, $attributes['userId']); + $this->assertSame('test', $attributes['scope']); + } else { + $this->assertArrayNotHasKey('userId', $attributes); + $this->assertArrayNotHasKey('scope', $attributes); + } + }); + + SentrySdk::setCurrentHub(new Hub($client)); + + $logger = new SentryLog([ + 'formatter' => [ + 'className' => DefaultFormatter::class, + 'includeDate' => false, + ], + ]); + + $logger->log($level, 'Message {userId}', ['userId' => 42, 'scope' => 'test']); + } + + public static function logLevelProvider(): array + { + return [ + 'warning' => [LogLevel::WARNING, LogLevel::WARNING, true], + 'error' => [LogLevel::ERROR, LogLevel::ERROR, false], + 'notice' => [LogLevel::NOTICE, LogLevel::INFO, true], + 'debug' => [LogLevel::DEBUG, LogLevel::DEBUG, true], + 'emergency' => [LogLevel::EMERGENCY, LogLevel::CRITICAL, true], + 'custom defaults to trace' => ['custom', LogLevel::DEBUG, true], + ]; + } + + public function testLogSkipsImmediateFlushWhenDeferred(): void + { + $client = Mockery::mock(ClientInterface::class); + $client->shouldReceive('getOptions')->andReturn(new Options([ + 'dsn' => 'https://public@example.com/1', + 'enable_logs' => true, + ])); + $client->shouldReceive('captureEvent') + ->once() + ->withArgs(function (Event $event): bool { + $logs = $event->getLogs(); + + $this->assertCount(1, $logs); + $this->assertSame('info: Deferred message', $logs[0]->getBody()); + $this->assertSame('test', $logs[0]->attributes()->toSimpleArray()['scope']); + + return true; + }) + ->andReturnNull(); + + SentrySdk::setCurrentHub(new Hub($client)); + + $logger = new SentryLog([ + 'formatter' => [ + 'className' => DefaultFormatter::class, + 'includeDate' => false, + ], + ]); + $logger->logsWillBeFlushed = true; + + $logger->log(LogLevel::INFO, 'Deferred message', ['scope' => 'test']); + + $logs = SentrySdk::getCurrentRuntimeContext()->getLogsAggregator()->all(); + $this->assertCount(1, $logs); + $this->assertSame('info: Deferred message', $logs[0]->getBody()); + $this->assertSame('test', $logs[0]->attributes()->toSimpleArray()['scope']); + + SentrySdk::getCurrentRuntimeContext()->getLogsAggregator()->flush(); + } + + public function testLegacyNamespaceAliasesToNewClass(): void + { + $this->expectDeprecationMessageMatches( + '/Use `CakeSentry\\\\Log\\\\Engine\\\\SentryLog` instead of `CakeSentry\\\\Log\\\\Engines\\\\SentryLog`\./', + function (): void { + require dirname(__DIR__, 4) . '/src/Log/Engines/SentryLog.php'; + }, + ); + + $legacyClass = 'CakeSentry\\Log\\Engines\\SentryLog'; + + $this->assertTrue(class_exists($legacyClass, false)); + $this->assertSame(SentryLog::class, get_class(new $legacyClass([]))); + } + + protected function createClientMock(callable $captureEventAssertion): ClientInterface + { + $client = Mockery::mock(ClientInterface::class); + $client->shouldReceive('getOptions')->andReturn(new Options([ + 'dsn' => 'https://public@example.com/1', + 'enable_logs' => true, + ])); + $client->shouldReceive('captureEvent') + ->once() + ->withArgs(function (Event $event) use ($captureEventAssertion): bool { + $captureEventAssertion($event); + + return true; + }) + ->andReturnNull(); + + return $client; + } +}