Skip to content

Commit 531b218

Browse files
authored
Add support for distributed tracing when running a console command (#460)
1 parent 7ba1559 commit 531b218

File tree

4 files changed

+280
-0
lines changed

4 files changed

+280
-0
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
- Add support for distributed tracing of Symfony request events (#423)
66
- Add support for distributed tracing of Twig template rendering (#430)
77
- Add support for distributed tracing of SQL queries while using Doctrine DBAL (#426)
8+
- Add support for distributed tracing when running a console command (#455)
89
- Added missing `capture-soft-fails` config schema option (#417)
910
- Deprecate the `Sentry\SentryBundle\EventListener\ConsoleCommandListener` class in favor of its parent class `Sentry\SentryBundle\EventListener\ConsoleListener` (#429)
1011

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Sentry\SentryBundle\EventListener;
6+
7+
use Sentry\State\HubInterface;
8+
use Sentry\Tracing\Span;
9+
use Sentry\Tracing\SpanContext;
10+
use Sentry\Tracing\Transaction;
11+
use Sentry\Tracing\TransactionContext;
12+
use Symfony\Component\Console\Command\Command;
13+
use Symfony\Component\Console\Event\ConsoleCommandEvent;
14+
use Symfony\Component\Console\Event\ConsoleTerminateEvent;
15+
16+
/**
17+
* This listener either starts a {@see Transaction} or a child {@see Span} when
18+
* a console command is executed to allow measuring the application performances.
19+
*/
20+
final class TracingConsoleListener
21+
{
22+
/**
23+
* @var HubInterface The current hub
24+
*/
25+
private $hub;
26+
27+
/**
28+
* Constructor.
29+
*
30+
* @param HubInterface $hub The current hub
31+
*/
32+
public function __construct(HubInterface $hub)
33+
{
34+
$this->hub = $hub;
35+
}
36+
37+
/**
38+
* Handles the execution of a console command by starting a new {@see Transaction}
39+
* if it doesn't exists, or a child {@see Span} if it does.
40+
*
41+
* @param ConsoleCommandEvent $event The event
42+
*/
43+
public function handleConsoleCommandEvent(ConsoleCommandEvent $event): void
44+
{
45+
$currentSpan = $this->hub->getSpan();
46+
47+
if (null === $currentSpan) {
48+
$transactionContext = new TransactionContext();
49+
$transactionContext->setOp('console.command');
50+
$transactionContext->setName($this->getSpanName($event->getCommand()));
51+
52+
$span = $this->hub->startTransaction($transactionContext);
53+
} else {
54+
$spanContext = new SpanContext();
55+
$spanContext->setOp('console.command');
56+
$spanContext->setDescription($this->getSpanName($event->getCommand()));
57+
58+
$span = $currentSpan->startChild($spanContext);
59+
}
60+
61+
$this->hub->setSpan($span);
62+
}
63+
64+
/**
65+
* Handles the termination of a console command by stopping the active {@see Span}
66+
* or {@see Transaction}.
67+
*
68+
* @param ConsoleTerminateEvent $event The event
69+
*/
70+
public function handleConsoleTerminateEvent(ConsoleTerminateEvent $event): void
71+
{
72+
$span = $this->hub->getSpan();
73+
74+
if (null !== $span) {
75+
$span->finish();
76+
}
77+
}
78+
79+
private function getSpanName(?Command $command): string
80+
{
81+
if (null === $command || null === $command->getName()) {
82+
return '<unnamed command>';
83+
}
84+
85+
return $command->getName();
86+
}
87+
}

src/Resources/config/services.xml

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,13 @@
7070
<tag name="kernel.event_listener" event="kernel.response" method="handleKernelResponseEvent" priority="15" />
7171
</service>
7272

73+
<service id="Sentry\SentryBundle\EventListener\TracingConsoleListener" class="Sentry\SentryBundle\EventListener\TracingConsoleListener">
74+
<argument type="service" id="Sentry\State\HubInterface" />
75+
76+
<tag name="kernel.event_listener" event="console.command" method="handleConsoleCommandEvent" priority="118" />
77+
<tag name="kernel.event_listener" event="console.terminate" method="handleConsoleTerminateEvent" priority="-54" />
78+
</service>
79+
7380
<service id="Sentry\SentryBundle\EventListener\MessengerListener" class="Sentry\SentryBundle\EventListener\MessengerListener">
7481
<argument type="service" id="Sentry\State\HubInterface" />
7582

Lines changed: 185 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,185 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Sentry\SentryBundle\Tests\EventListener;
6+
7+
use Generator;
8+
use PHPUnit\Framework\MockObject\MockObject;
9+
use PHPUnit\Framework\TestCase;
10+
use Sentry\SentryBundle\EventListener\TracingConsoleListener;
11+
use Sentry\State\HubInterface;
12+
use Sentry\Tracing\Span;
13+
use Sentry\Tracing\Transaction;
14+
use Sentry\Tracing\TransactionContext;
15+
use Symfony\Component\Console\Command\Command;
16+
use Symfony\Component\Console\Event\ConsoleCommandEvent;
17+
use Symfony\Component\Console\Event\ConsoleTerminateEvent;
18+
use Symfony\Component\Console\Input\InputInterface;
19+
use Symfony\Component\Console\Output\OutputInterface;
20+
21+
final class TracingConsoleListenerTest extends TestCase
22+
{
23+
/**
24+
* @var MockObject&HubInterface
25+
*/
26+
private $hub;
27+
28+
/**
29+
* @var TracingConsoleListener
30+
*/
31+
private $listener;
32+
33+
protected function setUp(): void
34+
{
35+
$this->hub = $this->createMock(HubInterface::class);
36+
$this->listener = new TracingConsoleListener($this->hub);
37+
}
38+
39+
/**
40+
* @dataProvider handleConsoleCommandEventStartsTransactionIfNoSpanIsSetOnHubDataProvider
41+
*/
42+
public function testHandleConsoleCommandEventStartsTransactionIfNoSpanIsSetOnHub(?Command $command, TransactionContext $expectedTransactionContext): void
43+
{
44+
$transaction = new Transaction(new TransactionContext());
45+
46+
$this->hub->expects($this->once())
47+
->method('getSpan')
48+
->willReturn(null);
49+
50+
$this->hub->expects($this->once())
51+
->method('startTransaction')
52+
->with($this->callback(function (TransactionContext $context) use ($expectedTransactionContext): bool {
53+
$this->assertEquals($expectedTransactionContext, $context);
54+
55+
return true;
56+
}))
57+
->willReturn($transaction);
58+
59+
$this->hub->expects($this->once())
60+
->method('setSpan')
61+
->with($transaction)
62+
->willReturnSelf();
63+
64+
$this->listener->handleConsoleCommandEvent(new ConsoleCommandEvent(
65+
$command,
66+
$this->createMock(InputInterface::class),
67+
$this->createMock(OutputInterface::class)
68+
));
69+
}
70+
71+
/**
72+
* @return Generator<mixed>
73+
*/
74+
public function handleConsoleCommandEventStartsTransactionIfNoSpanIsSetOnHubDataProvider(): Generator
75+
{
76+
$transactionContext = new TransactionContext();
77+
$transactionContext->setOp('console.command');
78+
$transactionContext->setName('<unnamed command>');
79+
80+
yield [
81+
null,
82+
$transactionContext,
83+
];
84+
85+
$transactionContext = new TransactionContext();
86+
$transactionContext->setOp('console.command');
87+
$transactionContext->setName('<unnamed command>');
88+
89+
yield [
90+
new Command(),
91+
$transactionContext,
92+
];
93+
94+
$transactionContext = new TransactionContext();
95+
$transactionContext->setOp('console.command');
96+
$transactionContext->setName('app:command');
97+
98+
yield [
99+
new Command('app:command'),
100+
$transactionContext,
101+
];
102+
}
103+
104+
/**
105+
* @dataProvider handleConsoleCommandEventStartsChildSpanIfSpanIsSetOnHubDataProvider
106+
*/
107+
public function testHandleConsoleCommandEventStartsChildSpanIfSpanIsSetOnHub(?Command $command, string $expectedDescription): void
108+
{
109+
$span = new Span();
110+
111+
$this->hub->expects($this->once())
112+
->method('getSpan')
113+
->willReturn($span);
114+
115+
$this->hub->expects($this->once())
116+
->method('setSpan')
117+
->with($this->callback(function (Span $spanArg) use ($span, $expectedDescription): bool {
118+
$this->assertSame('console.command', $spanArg->getOp());
119+
$this->assertSame($expectedDescription, $spanArg->getDescription());
120+
$this->assertSame($span->getSpanId(), $spanArg->getParentSpanId());
121+
122+
return true;
123+
}))
124+
->willReturnSelf();
125+
126+
$this->listener->handleConsoleCommandEvent(new ConsoleCommandEvent(
127+
$command,
128+
$this->createMock(InputInterface::class),
129+
$this->createMock(OutputInterface::class)
130+
));
131+
}
132+
133+
/**
134+
* @return Generator<mixed>
135+
*/
136+
public function handleConsoleCommandEventStartsChildSpanIfSpanIsSetOnHubDataProvider(): Generator
137+
{
138+
yield [
139+
null,
140+
'<unnamed command>',
141+
];
142+
143+
yield [
144+
new Command(),
145+
'<unnamed command>',
146+
];
147+
148+
yield [
149+
new Command('app:command'),
150+
'app:command',
151+
];
152+
}
153+
154+
public function testHandleConsoleTerminateEvent(): void
155+
{
156+
$span = new Span();
157+
158+
$this->hub->expects($this->once())
159+
->method('getSpan')
160+
->willReturn($span);
161+
162+
$this->listener->handleConsoleTerminateEvent(new ConsoleTerminateEvent(
163+
new Command(),
164+
$this->createMock(InputInterface::class),
165+
$this->createMock(OutputInterface::class),
166+
0
167+
));
168+
169+
$this->assertNotNull($span->getEndTimestamp());
170+
}
171+
172+
public function testHandleConsoleTerminateEventDoesNothingIfNoSpanIsSetOnHub(): void
173+
{
174+
$this->hub->expects($this->once())
175+
->method('getSpan')
176+
->willReturn(null);
177+
178+
$this->listener->handleConsoleTerminateEvent(new ConsoleTerminateEvent(
179+
new Command(),
180+
$this->createMock(InputInterface::class),
181+
$this->createMock(OutputInterface::class),
182+
0
183+
));
184+
}
185+
}

0 commit comments

Comments
 (0)