Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 64 additions & 0 deletions src/Log/Engine/SentryLog.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
<?php
declare(strict_types=1);

namespace CakeSentry\Log\Engine;

use Cake\Event\EventManager;
use Cake\Log\Engine\BaseLog;
use Psr\Log\LogLevel;
use Sentry\Logs\Logs;
use Stringable;

class SentryLog extends BaseLog
{
public bool $logsWillBeFlushed = false;

/**
* @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();
}
}
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

class_alias() call missing at the bottom of the file, similar to https://github.com/cakephp/cakephp/blob/5.next/src/Console/Helper/BannerHelper.php#L110

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

done


// phpcs:disable
class_alias('CakeSentry\Log\Engine\SentryLog', 'CakeSentry\Log\Engines\SentryLog');
// phpcs:enable
58 changes: 5 additions & 53 deletions src/Log/Engines/SentryLog.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

People usually don't like explicit deprecation warnings in a patch release.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Then people can complain about that to me 😉


/**
* @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);
160 changes: 160 additions & 0 deletions tests/TestCase/Log/Engine/SentryLogTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
<?php
declare(strict_types=1);

namespace CakeSentry\Test\TestCase\Log\Engine;

use Cake\Log\Formatter\DefaultFormatter;
use Cake\TestSuite\TestCase;
use CakeSentry\Log\Engine\SentryLog;
use Mockery;
use Mockery\Adapter\Phpunit\MockeryPHPUnitIntegration;
use PHPUnit\Framework\Attributes\DataProvider;
use Psr\Log\LogLevel;
use Sentry\ClientInterface;
use Sentry\Event;
use Sentry\Options;
use Sentry\SentrySdk;
use Sentry\State\Hub;

class SentryLogTest extends TestCase
{
use MockeryPHPUnitIntegration;

protected Hub $originalHub;

public function setUp(): void
{
parent::setUp();
$this->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;
}
}