Skip to content

Commit ee4fd84

Browse files
committed
fix(command): fix breadcrumbs and tag propagation for console commands
1 parent a742733 commit ee4fd84

File tree

5 files changed

+319
-2
lines changed

5 files changed

+319
-2
lines changed
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Sentry\SentryBundle\Command;
6+
7+
use Psr\Log\LoggerInterface;
8+
use Symfony\Component\Console\Command\Command;
9+
use Symfony\Component\Console\Input\InputInterface;
10+
use Symfony\Component\Console\Output\OutputInterface;
11+
12+
class SentryBreadcrumbTestCommand extends Command
13+
{
14+
/**
15+
* @var LoggerInterface
16+
*/
17+
private $logger;
18+
19+
public function __construct(LoggerInterface $logger)
20+
{
21+
parent::__construct('sentry:breadcrumb:test');
22+
$this->logger = $logger;
23+
}
24+
25+
protected function execute(InputInterface $input, OutputInterface $output): int
26+
{
27+
$this->logger->error('Breadcrumb error');
28+
29+
throw new \RuntimeException('Breadcrumb error');
30+
}
31+
}
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Sentry\SentryBundle\Command;
6+
7+
use Psr\Log\LoggerInterface;
8+
use Symfony\Component\Console\Command\Command;
9+
use Symfony\Component\Console\Input\InputInterface;
10+
use Symfony\Component\Console\Output\OutputInterface;
11+
12+
class SentryDummyTestCommand extends Command
13+
{
14+
/**
15+
* @var LoggerInterface
16+
*/
17+
private $logger;
18+
19+
public function __construct(LoggerInterface $logger)
20+
{
21+
parent::__construct('sentry:dummy:test');
22+
$this->logger = $logger;
23+
}
24+
25+
protected function execute(InputInterface $input, OutputInterface $output): int
26+
{
27+
$this->logger->error('This is a dummy message');
28+
29+
return 0;
30+
}
31+
}
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Sentry\SentryBundle\Command;
6+
7+
use Psr\Log\LoggerInterface;
8+
use Symfony\Component\Console\Command\Command;
9+
use Symfony\Component\Console\Input\ArrayInput;
10+
use Symfony\Component\Console\Input\InputInterface;
11+
use Symfony\Component\Console\Output\NullOutput;
12+
use Symfony\Component\Console\Output\OutputInterface;
13+
14+
class SentrySubcommandTestCommand extends Command
15+
{
16+
/**
17+
* @var LoggerInterface
18+
*/
19+
private $logger;
20+
21+
/**
22+
* @var Command
23+
*/
24+
private $subcommand;
25+
26+
public function __construct(LoggerInterface $logger, Command $subcommand)
27+
{
28+
parent::__construct('sentry:subcommand:test');
29+
$this->logger = $logger;
30+
$this->subcommand = $subcommand;
31+
}
32+
33+
protected function execute(InputInterface $input, OutputInterface $output): int
34+
{
35+
$this->logger->error('Subcommand will run now');
36+
37+
$this->getApplication()->doRun(new ArrayInput(['command' => $this->subcommand->getName()]), new NullOutput());
38+
39+
$this->logger->error('Breadcrumb after subcommand');
40+
41+
return 0;
42+
}
43+
}

src/EventListener/ConsoleListener.php

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,11 @@ class ConsoleListener
3232
*/
3333
private $captureErrors;
3434

35+
/**
36+
* @var bool tracks if the command terminated with errors
37+
*/
38+
private $commandHasErrors;
39+
3540
/**
3641
* Constructor.
3742
*
@@ -42,6 +47,7 @@ public function __construct(HubInterface $hub, bool $captureErrors = true)
4247
{
4348
$this->hub = $hub;
4449
$this->captureErrors = $captureErrors;
50+
$this->commandHasErrors = false;
4551
}
4652

4753
/**
@@ -65,13 +71,18 @@ public function handleConsoleCommandEvent(ConsoleCommandEvent $event): void
6571
}
6672

6773
/**
68-
* Handles the termination of a console command by popping the {@see Scope}.
74+
* Handles the termination of a console command. If the command had errors, we will not pop the scope
75+
* to retain information that we can attach to the event.
6976
*
7077
* @param ConsoleTerminateEvent $event The event
7178
*/
7279
public function handleConsoleTerminateEvent(ConsoleTerminateEvent $event): void
7380
{
74-
$this->hub->popScope();
81+
// We need to keep the last scope around even until command termination to retain
82+
// information when flushing buffered events.
83+
if (false === $this->commandHasErrors) {
84+
$this->hub->popScope();
85+
}
7586
}
7687

7788
/**
@@ -81,6 +92,7 @@ public function handleConsoleTerminateEvent(ConsoleTerminateEvent $event): void
8192
*/
8293
public function handleConsoleErrorEvent(ConsoleErrorEvent $event): void
8394
{
95+
$this->commandHasErrors = true;
8496
$this->hub->configureScope(function (Scope $scope) use ($event): void {
8597
$scope->setTag('console.command.exit_code', (string) $event->getExitCode());
8698

Lines changed: 200 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,200 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Command;
6+
7+
use Monolog\Logger;
8+
use PHPUnit\Framework\TestCase;
9+
use Psr\Log\LoggerInterface;
10+
use Sentry\Client;
11+
use Sentry\Event;
12+
use Sentry\Monolog\BreadcrumbHandler;
13+
use Sentry\Options;
14+
use Sentry\SentryBundle\Command\SentryBreadcrumbTestCommand;
15+
use Sentry\SentryBundle\Command\SentryDummyTestCommand;
16+
use Sentry\SentryBundle\Command\SentrySubcommandTestCommand;
17+
use Sentry\SentryBundle\EventListener\ConsoleListener;
18+
use Sentry\SentryBundle\Tests\End2End\StubTransport;
19+
use Sentry\State\Hub;
20+
use Sentry\State\HubInterface;
21+
use Sentry\State\Scope;
22+
use Symfony\Component\Console\Application;
23+
use Symfony\Component\Console\ConsoleEvents;
24+
use Symfony\Component\Console\Input\ArgvInput;
25+
use Symfony\Component\Console\Output\NullOutput;
26+
use Symfony\Component\EventDispatcher\EventDispatcher;
27+
28+
class BreadcrumbTestCommandTest extends TestCase
29+
{
30+
/**
31+
* @var HubInterface
32+
*/
33+
private $hub;
34+
35+
/**
36+
* @var LoggerInterface
37+
*/
38+
private $logger;
39+
40+
/**
41+
* @var Application
42+
*/
43+
private $application;
44+
45+
protected function setUp(): void
46+
{
47+
parent::setUp();
48+
$client = new Client(new Options(), new StubTransport());
49+
$hub = new Hub($client);
50+
51+
$consoleListener = new ConsoleListener($hub);
52+
53+
$dispatcher = new EventDispatcher();
54+
$dispatcher->addListener(ConsoleEvents::COMMAND, [$consoleListener, 'handleConsoleCommandEvent']);
55+
$dispatcher->addListener(ConsoleEvents::TERMINATE, [$consoleListener, 'handleConsoleTerminateEvent']);
56+
$dispatcher->addListener(ConsoleEvents::ERROR, [$consoleListener, 'handleConsoleErrorEvent']);
57+
58+
$this->application = new Application();
59+
$this->application->setDispatcher($dispatcher);
60+
61+
$breadcrumbHandler = new BreadcrumbHandler($hub);
62+
$this->hub = $hub;
63+
$this->logger = new Logger('test', [$breadcrumbHandler]);
64+
}
65+
66+
/**
67+
* Tests that breadcrumbs are properly captured within a console command and not lost
68+
* on command termination.
69+
*
70+
* @return void
71+
*/
72+
public function testBreadcrumbWithConsoleListener()
73+
{
74+
$command = new SentryBreadcrumbTestCommand($this->logger);
75+
$this->application->add($command);
76+
77+
try {
78+
// We need to run this by the application directly because the CommandTester doesn't produce proper events.
79+
$this->application->doRun(new ArgvInput(['bin/console', 'sentry:breadcrumb:test']), new NullOutput());
80+
$this->fail();
81+
} catch (\Throwable $e) {
82+
$this->assertSame($e->getMessage(), 'Breadcrumb error');
83+
}
84+
85+
$event = Event::createEvent();
86+
$modifiedEvent = null;
87+
88+
$this->hub->configureScope(function (Scope $scope) use ($event, &$modifiedEvent) {
89+
$modifiedEvent = $scope->applyToEvent($event);
90+
});
91+
92+
$this->assertNotNull($modifiedEvent);
93+
$this->assertCount(1, $modifiedEvent->getBreadcrumbs());
94+
}
95+
96+
/**
97+
* Tests that the scope is reset after the command finished without any errors.
98+
*
99+
* @return void
100+
*
101+
* @throws \Throwable
102+
*/
103+
public function testSubCommandBreadcrumbs()
104+
{
105+
$subcommand = new SentryDummyTestCommand($this->logger);
106+
$this->application->add($subcommand);
107+
108+
$command = new SentrySubcommandTestCommand($this->logger, $subcommand);
109+
$this->application->add($command);
110+
111+
// We need to run this by the application directly because the CommandTester doesn't produce proper events.
112+
$this->application->doRun(new ArgvInput(['bin/console', 'sentry:subcommand:test']), new NullOutput());
113+
114+
$event = Event::createEvent();
115+
$modifiedEvent = null;
116+
117+
$this->hub->configureScope(function (Scope $scope) use ($event, &$modifiedEvent) {
118+
$modifiedEvent = $scope->applyToEvent($event);
119+
});
120+
121+
$this->assertNotNull($modifiedEvent);
122+
// We do not have breadcrumbs here because no error happened and the scope was popped.
123+
$this->assertCount(0, $modifiedEvent->getBreadcrumbs());
124+
}
125+
126+
/**
127+
* Tests that the command that caused the crash is reported as `console.command` tag.
128+
*
129+
* @return void
130+
*/
131+
public function testCrashingSubcommand()
132+
{
133+
$subcommand = new SentryBreadcrumbTestCommand($this->logger);
134+
$this->application->add($subcommand);
135+
136+
$command = new SentrySubcommandTestCommand($this->logger, $subcommand);
137+
$this->application->add($command);
138+
139+
try {
140+
$this->application->doRun(new ArgvInput(['bin/console', 'sentry:subcommand:test']), new NullOutput());
141+
$this->fail();
142+
} catch (\Throwable $e) {
143+
$this->assertSame($e->getMessage(), 'Breadcrumb error');
144+
}
145+
146+
$event = Event::createEvent();
147+
$modifiedEvent = null;
148+
149+
$this->hub->configureScope(function (Scope $scope) use ($event, &$modifiedEvent) {
150+
$modifiedEvent = $scope->applyToEvent($event);
151+
});
152+
153+
$this->assertNotNull($modifiedEvent);
154+
$this->assertCount(2, $modifiedEvent->getBreadcrumbs());
155+
$this->assertSame($modifiedEvent->getTags()['console.command'], 'sentry:breadcrumb:test');
156+
}
157+
158+
/**
159+
* Tests that we have the correct `console.command` tag if one command throws an unhandled exception
160+
* even if we had commands that threw exceptions but were handled.
161+
*
162+
* @return void
163+
*
164+
* @throws \Throwable
165+
*/
166+
public function testRunSecondCommandAfterCrashingCommand()
167+
{
168+
$subcommand = new SentryBreadcrumbTestCommand($this->logger);
169+
$this->application->add($subcommand);
170+
171+
$command = new SentrySubcommandTestCommand($this->logger, $subcommand);
172+
$this->application->add($command);
173+
174+
try {
175+
// Run first command which crashes but is handled
176+
$this->application->doRun(new ArgvInput(['bin/console', 'sentry:subcommand:test']), new NullOutput());
177+
$this->fail();
178+
} catch (\Throwable $e) {
179+
$this->assertSame($e->getMessage(), 'Breadcrumb error');
180+
}
181+
182+
$command = new SentryDummyTestCommand($this->logger);
183+
$this->application->add($command);
184+
185+
// Run the second command which crashes unhandled.
186+
$this->application->doRun(new ArgvInput(['bin/console', 'sentry:dummy:test']), new NullOutput());
187+
188+
$event = Event::createEvent();
189+
$modifiedEvent = null;
190+
$this->hub->configureScope(function (Scope $scope) use ($event, &$modifiedEvent) {
191+
$modifiedEvent = $scope->applyToEvent($event);
192+
});
193+
194+
$this->assertNotNull($modifiedEvent);
195+
// Breadcrumbs contain all log entries until the sentry:dummy:test command crash.
196+
$this->assertCount(3, $modifiedEvent->getBreadcrumbs());
197+
// console.command tag is properly set to the last command
198+
$this->assertSame($modifiedEvent->getTags()['console.command'], 'sentry:dummy:test');
199+
}
200+
}

0 commit comments

Comments
 (0)