Skip to content

fix: add buffer flusher for sentry handler #936

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 21 commits into
base: master
Choose a base branch
from
Open
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
70 changes: 70 additions & 0 deletions src/DependencyInjection/Compiler/BufferFlushPass.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
<?php

declare(strict_types=1);

namespace Sentry\SentryBundle\DependencyInjection\Compiler;

use Monolog\Handler\BufferHandler;
use Sentry\Monolog\Handler as SentryHandler;
use Sentry\SentryBundle\EventListener\BufferFlusher;
use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\DependencyInjection\Definition;
use Symfony\Component\DependencyInjection\Reference;

class BufferFlushPass implements CompilerPassInterface
{
public function process(ContainerBuilder $container): void
{
$sentryBufferHandlers = $this->findSentryBufferHandlers($container);

if (empty($sentryBufferHandlers)) {
return;
}

$flusherDefinition = new Definition(BufferFlusher::class);
$flusherDefinition->setArguments([$sentryBufferHandlers]);
$flusherDefinition->addTag('kernel.event_subscriber');

$container->setDefinition('sentry.buffer_flusher', $flusherDefinition);
}

/**
* Finds all {@link BufferHandler} that wrap {@link SentryHandler} and register a service
* that will flush them on KernelEvents::TERMINATE to make sure that all events retain
* breadcrumbs and context information.
*
* @return Reference[]
*/
private function findSentryBufferHandlers(ContainerBuilder $container): array
{
$sentryBufferHandlers = [];

foreach ($container->getDefinitions() as $serviceId => $definition) {
if (BufferHandler::class === $definition->getClass()) {
$arguments = $definition->getArguments();
if (!empty($arguments)) {
// The first argument of BufferHandler is the HandlerInterface, which
// can be a SentryHandler.
$firstArgument = $arguments[0];

if ($firstArgument instanceof Reference) {
$referencedServiceId = (string) $firstArgument;
try {
$referencedDefinition = $container->findDefinition($referencedServiceId);

if (SentryHandler::class === $referencedDefinition->getClass()) {
$sentryBufferHandlers[] = new Reference($serviceId);
}
} catch (\Exception $e) {
// If the service from the first argument doesn't exist we just keep going
continue;
}
}
}
}
}

return $sentryBufferHandlers;
}
}
80 changes: 80 additions & 0 deletions src/EventListener/BufferFlusher.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
<?php

declare(strict_types=1);

namespace Sentry\SentryBundle\EventListener;

use Monolog\Handler\BufferHandler;
use Symfony\Component\Console\ConsoleEvents;
use Symfony\Component\Console\Event\ConsoleCommandEvent;
use Symfony\Component\Console\Event\ConsoleErrorEvent;
use Symfony\Component\Console\Event\ConsoleTerminateEvent;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpKernel\Event\TerminateEvent;
use Symfony\Component\HttpKernel\KernelEvents;

/**
* Class that wraps Sentry monolog handlers to flush them for certain lifecycle events.
* This is required to emit proper scope based information like tags and breadcrumbs.
*
* Without this class, buffered monolog messages are flushed when the request finishes at which
* point breadcrumbs and tags are no longer present in the scope.
*/
class BufferFlusher implements EventSubscriberInterface
{
/**
* @var BufferHandler[]
*/
private $bufferHandlers;

/**
* @param BufferHandler[] $bufferHandlers
*/
public function __construct(array $bufferHandlers = [])
{
$this->bufferHandlers = $bufferHandlers;
}

public static function getSubscribedEvents(): array
{
// Flush the Monolog buffer before any scope is destroyed so that events
// get augmented with properly scoped data.
// For ConsoleEvents::COMMAND, we have to flush before ConsoleListener::handleConsoleCommandEvent(..)
// runs so that the proper tags get attached to the event.
// Running with lower priority will make the ConsoleListener run before and create a new scope
// with the new command name when running a symfony Command within another Command.
return [
KernelEvents::TERMINATE => ['handleKernelTerminateEvent', 10],
ConsoleEvents::COMMAND => ['handleConsoleCommandEvent', 150],
ConsoleEvents::TERMINATE => ['handleConsoleTerminateEvent', 10],
ConsoleEvents::ERROR => ['handleConsoleErrorEvent', 10],
];
}

public function handleKernelTerminateEvent(TerminateEvent $event): void
{
$this->flushBuffers();
}

public function handleConsoleTerminateEvent(ConsoleTerminateEvent $event): void
{
$this->flushBuffers();
}

public function handleConsoleErrorEvent(ConsoleErrorEvent $event): void
{
$this->flushBuffers();
}

public function handleConsoleCommandEvent(ConsoleCommandEvent $event): void
{
$this->flushBuffers();
}

private function flushBuffers(): void
{
foreach ($this->bufferHandlers as $handler) {
$handler->flush();
}
}
}
2 changes: 2 additions & 0 deletions src/SentryBundle.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
namespace Sentry\SentryBundle;

use Sentry\SentryBundle\DependencyInjection\Compiler\AddLoginListenerTagPass;
use Sentry\SentryBundle\DependencyInjection\Compiler\BufferFlushPass;
use Sentry\SentryBundle\DependencyInjection\Compiler\CacheTracingPass;
use Sentry\SentryBundle\DependencyInjection\Compiler\DbalTracingPass;
use Sentry\SentryBundle\DependencyInjection\Compiler\HttpClientTracingPass;
Expand All @@ -26,5 +27,6 @@ public function build(ContainerBuilder $container): void
$container->addCompilerPass(new CacheTracingPass());
$container->addCompilerPass(new HttpClientTracingPass());
$container->addCompilerPass(new AddLoginListenerTagPass());
$container->addCompilerPass(new BufferFlushPass());
}
}
119 changes: 119 additions & 0 deletions tests/DependencyInjection/Compiler/BufferFlushPassTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
<?php

declare(strict_types=1);

namespace Sentry\SentryBundle\Tests\DependencyInjection\Compiler;

use Monolog\Handler\BufferHandler;
use Monolog\Handler\NullHandler;
use PHPUnit\Framework\TestCase;
use Sentry\Monolog\Handler as SentryHandler;
use Sentry\SentryBundle\DependencyInjection\Compiler\BufferFlushPass;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\DependencyInjection\Definition;
use Symfony\Component\DependencyInjection\Reference;

class BufferFlushPassTest extends TestCase
{
/**
* @param Reference[] $services
*
* @return string[]
*/
private function servicesToName(array $services): array
{
return array_map(function ($item) {
return (string) $item;
}, $services);
}

/**
* @param Definition $definition
*
* @return string[]
*/
private function argumentToName(Definition $definition): array
{
$argument = $definition->getArgument(0);
$this->assertIsArray($argument);
$this->assertInstanceOf(Reference::class, $argument[0]);

return $this->servicesToName($argument);
}

/**
* Tests that the flusher will only container references to handler that wrap sentry.
*
* @return void
*/
public function testProcessWithMultipleHandlers()
{
$container = new ContainerBuilder();
$container->setDefinition('sentry.handler', new Definition(SentryHandler::class));
$container->setDefinition('null.handler', new Definition(NullHandler::class));
$container->setDefinition('sentry.test.handler', new Definition(BufferHandler::class, [new Reference('sentry.handler')]));
$container->setDefinition('other.test.handler', new Definition(BufferHandler::class, [new Reference('null.handler')]));

(new BufferFlushPass())->process($container);
$definition = $container->getDefinition('sentry.buffer_flusher');
$serviceIds = $this->argumentToName($definition);
$this->assertEquals(['sentry.test.handler'], $serviceIds);
}

/**
* Tests that if no sentry handlers exist, there is also no flusher.
*
* @return void
*/
public function testProcessWithoutSentryHandler()
{
$container = new ContainerBuilder();
$container->setDefinition('null.handler', new Definition(NullHandler::class));
$container->setDefinition('other.test.handler', new Definition(BufferHandler::class, [new Reference('null.handler')]));

(new BufferFlushPass())->process($container);
$this->assertFalse($container->hasDefinition('sentry.buffer_flusher'));
}

/**
* Tests that even if there are multiple sentry handler (for some reason), it will only
* collect them and no others.
*
* @return void
*/
public function testProcessWithMultipleSentryHandlers()
{
$container = new ContainerBuilder();
$container->setDefinition('sentry.handler', new Definition(SentryHandler::class));
$container->setDefinition('sentry.other.handler', new Definition(SentryHandler::class));
$container->setDefinition('null.handler', new Definition(NullHandler::class));
$container->setDefinition('sentry.test.handler', new Definition(BufferHandler::class, [new Reference('sentry.handler')]));
$container->setDefinition('sentry.other.test.handler', new Definition(BufferHandler::class, [new Reference('sentry.other.handler')]));
$container->setDefinition('other.test.handler', new Definition(BufferHandler::class, [new Reference('null.handler')]));

(new BufferFlushPass())->process($container);
$definition = $container->getDefinition('sentry.buffer_flusher');
$serviceIds = $this->argumentToName($definition);
$this->assertEquals(['sentry.test.handler', 'sentry.other.test.handler'], $serviceIds);
}

/**
* Tests that handlers that are named sentry will not be flushed because the matching happens by class
* name and not by service id.
*
* @return void
*/
public function testProcessWithFakeSentryHandlers()
{
$container = new ContainerBuilder();
$container->setDefinition('sentry.handler', new Definition(SentryHandler::class));
$container->setDefinition('sentry.fake.handler', new Definition(NullHandler::class));
$container->setDefinition('sentry.test.handler', new Definition(BufferHandler::class, [new Reference('sentry.handler')]));
$container->setDefinition('sentry.fake.test.handler', new Definition(BufferHandler::class, [new Reference('null.handler')]));

(new BufferFlushPass())->process($container);
$definition = $container->getDefinition('sentry.buffer_flusher');
$serviceIds = $this->argumentToName($definition);
$this->assertEquals(['sentry.test.handler'], $serviceIds);
}
}
31 changes: 31 additions & 0 deletions tests/End2End/App/Command/BreadcrumbTestCommand.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
<?php

declare(strict_types=1);

namespace Sentry\SentryBundle\Tests\End2End\App\Command;

use Psr\Log\LoggerInterface;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;

class BreadcrumbTestCommand extends Command
{
/**
* @var LoggerInterface
*/
private $logger;

public function __construct(LoggerInterface $logger)
{
parent::__construct();
$this->logger = $logger;
}

protected function execute(InputInterface $input, OutputInterface $output): int
{
$this->logger->error('breadcrumb 1 error');

throw new \RuntimeException('Breadcrumb error');
}
}
39 changes: 39 additions & 0 deletions tests/End2End/App/Command/CrashingSubcommandTestCommand.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
<?php

declare(strict_types=1);

namespace Sentry\SentryBundle\Tests\End2End\App\Command;

use Psr\Log\LoggerInterface;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\ArrayInput;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\NullOutput;
use Symfony\Component\Console\Output\OutputInterface;

class CrashingSubcommandTestCommand extends Command
{
/**
* @var LoggerInterface
*/
private $logger;

public function __construct(LoggerInterface $logger)
{
parent::__construct();
$this->logger = $logger;
}

protected function execute(InputInterface $input, OutputInterface $output): int
{
$this->logger->error('subcommand crash 1 error');

if (null !== $this->getApplication()) {
$this->getApplication()->doRun(new ArrayInput(['command' => 'sentry:breadcrumb:test']), new NullOutput());
}

$this->logger->error('subcommand error 2 error');

return 0;
}
}
31 changes: 31 additions & 0 deletions tests/End2End/App/Command/DummyTestCommand.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
<?php

declare(strict_types=1);

namespace Sentry\SentryBundle\Tests\End2End\App\Command;

use Psr\Log\LoggerInterface;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;

class DummyTestCommand extends Command
{
/**
* @var LoggerInterface
*/
private $logger;

public function __construct(LoggerInterface $logger)
{
parent::__construct();
$this->logger = $logger;
}

protected function execute(InputInterface $input, OutputInterface $output): int
{
$this->logger->error('dummy 1 error');

return 0;
}
}
Loading
Loading