Skip to content

Commit 5d1cc18

Browse files
committed
feat(symfony): create root span for console command execution
Auto-instrument Symfony console commands with an INTERNAL root span (name, exit code, exception/status on failure), matching the HTTP kernel instrumentation. Fixes open-telemetry/opentelemetry-php#1774. Risk-Level: medium AI-Agent: claude-sonnet-5
1 parent 176b3fc commit 5d1cc18

4 files changed

Lines changed: 199 additions & 1 deletion

File tree

src/Instrumentation/Symfony/_register.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
declare(strict_types=1);
44

5+
use OpenTelemetry\Contrib\Instrumentation\Symfony\ConsoleInstrumentation;
56
use OpenTelemetry\Contrib\Instrumentation\Symfony\HttpClientInstrumentation;
67
use OpenTelemetry\Contrib\Instrumentation\Symfony\MessengerInstrumentation;
78
use OpenTelemetry\Contrib\Instrumentation\Symfony\SymfonyInstrumentation;
@@ -20,3 +21,4 @@
2021
SymfonyInstrumentation::register();
2122
MessengerInstrumentation::register();
2223
HttpClientInstrumentation::register();
24+
ConsoleInstrumentation::register();

src/Instrumentation/Symfony/composer.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,8 @@
3131
"phpunit/phpunit": "^9.5",
3232
"vimeo/psalm": "6.4.0",
3333
"symfony/http-client": "^5.4||^6.0",
34-
"symfony/messenger": "^5.4||^6.0"
34+
"symfony/messenger": "^5.4||^6.0",
35+
"symfony/console": "^5.4||^6.0"
3536
},
3637
"autoload": {
3738
"psr-4": {
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace OpenTelemetry\Contrib\Instrumentation\Symfony;
6+
7+
use OpenTelemetry\API\Instrumentation\CachedInstrumentation;
8+
use OpenTelemetry\API\Trace\Span;
9+
use OpenTelemetry\API\Trace\SpanKind;
10+
use OpenTelemetry\API\Trace\StatusCode;
11+
use OpenTelemetry\Context\Context;
12+
use function OpenTelemetry\Instrumentation\hook;
13+
use OpenTelemetry\SemConv\TraceAttributes;
14+
use OpenTelemetry\SemConv\Version;
15+
use Symfony\Component\Console\Command\Command;
16+
17+
/**
18+
* Creates a default root span for console command execution, mirroring the
19+
* root span created by the HttpKernel instrumentation for HTTP requests.
20+
*/
21+
final class ConsoleInstrumentation
22+
{
23+
const ATTRIBUTE_CONSOLE_EXIT_CODE = 'symfony.console.exit_code';
24+
25+
/** @psalm-suppress PossiblyUnusedMethod */
26+
public static function register(): void
27+
{
28+
$instrumentation = new CachedInstrumentation(
29+
'io.opentelemetry.contrib.php.symfony_console',
30+
null,
31+
Version::VERSION_1_32_0->url(),
32+
);
33+
34+
/** @psalm-suppress UnusedFunctionCall */
35+
hook(
36+
Command::class,
37+
'run',
38+
pre: static function (
39+
Command $command,
40+
array $params,
41+
string $class,
42+
string $function,
43+
?string $filename,
44+
?int $lineno,
45+
) use ($instrumentation): array {
46+
$name = $command->getName() ?? 'command';
47+
48+
/** @psalm-suppress ArgumentTypeCoercion */
49+
$builder = $instrumentation
50+
->tracer()
51+
->spanBuilder($name)
52+
->setSpanKind(SpanKind::KIND_INTERNAL)
53+
->setAttribute(TraceAttributes::CODE_FUNCTION_NAME, sprintf('%s::%s', $class, $function))
54+
->setAttribute(TraceAttributes::CODE_FILE_PATH, $filename)
55+
->setAttribute(TraceAttributes::CODE_LINE_NUMBER, $lineno);
56+
57+
$parent = Context::getCurrent();
58+
$span = $builder
59+
->setParent($parent)
60+
->startSpan();
61+
62+
Context::storage()->attach($span->storeInContext($parent));
63+
64+
return $params;
65+
},
66+
post: static function (
67+
Command $command,
68+
array $params,
69+
?int $returnValue,
70+
?\Throwable $exception
71+
): void {
72+
$scope = Context::storage()->scope();
73+
if (null === $scope) {
74+
return;
75+
}
76+
77+
$scope->detach();
78+
$span = Span::fromContext($scope->context());
79+
80+
if (null !== $exception) {
81+
$span->recordException($exception);
82+
$span->setStatus(StatusCode::STATUS_ERROR, $exception->getMessage());
83+
} elseif (null !== $returnValue && $returnValue !== Command::SUCCESS) {
84+
$span->setStatus(StatusCode::STATUS_ERROR);
85+
}
86+
87+
if (null !== $returnValue) {
88+
$span->setAttribute(self::ATTRIBUTE_CONSOLE_EXIT_CODE, $returnValue);
89+
}
90+
91+
$span->end();
92+
}
93+
);
94+
}
95+
}
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace OpenTelemetry\Tests\Instrumentation\Symfony\tests\Integration;
6+
7+
use OpenTelemetry\API\Trace\SpanKind;
8+
use OpenTelemetry\API\Trace\StatusCode;
9+
use OpenTelemetry\Contrib\Instrumentation\Symfony\ConsoleInstrumentation;
10+
use Symfony\Component\Console\Command\Command;
11+
use Symfony\Component\Console\Input\ArrayInput;
12+
use Symfony\Component\Console\Output\NullOutput;
13+
14+
final class ConsoleInstrumentationTest extends AbstractTest
15+
{
16+
public function test_command_run_creates_root_span(): void
17+
{
18+
$command = new class() extends Command {
19+
protected static $defaultName = 'app:test-command';
20+
21+
protected function configure(): void
22+
{
23+
$this->setName('app:test-command');
24+
}
25+
26+
protected function execute($input, $output): int
27+
{
28+
return Command::SUCCESS;
29+
}
30+
};
31+
32+
$this->assertCount(0, $this->storage);
33+
34+
$exitCode = $command->run(new ArrayInput([]), new NullOutput());
35+
36+
$this->assertSame(Command::SUCCESS, $exitCode);
37+
$this->assertCount(1, $this->storage);
38+
39+
$span = $this->storage[0];
40+
$this->assertSame('app:test-command', $span->getName());
41+
$this->assertSame(SpanKind::KIND_INTERNAL, $span->getKind());
42+
$this->assertSame(Command::SUCCESS, $span->getAttributes()->get(ConsoleInstrumentation::ATTRIBUTE_CONSOLE_EXIT_CODE));
43+
$this->assertNotSame(StatusCode::STATUS_ERROR, $span->getStatus()->getCode());
44+
}
45+
46+
public function test_command_run_with_non_zero_exit_code_is_error(): void
47+
{
48+
$command = new class() extends Command {
49+
protected static $defaultName = 'app:failing-command';
50+
51+
protected function configure(): void
52+
{
53+
$this->setName('app:failing-command');
54+
}
55+
56+
protected function execute($input, $output): int
57+
{
58+
return Command::FAILURE;
59+
}
60+
};
61+
62+
$exitCode = $command->run(new ArrayInput([]), new NullOutput());
63+
64+
$this->assertSame(Command::FAILURE, $exitCode);
65+
$this->assertCount(1, $this->storage);
66+
67+
$span = $this->storage[0];
68+
$this->assertSame(StatusCode::STATUS_ERROR, $span->getStatus()->getCode());
69+
$this->assertSame(Command::FAILURE, $span->getAttributes()->get(ConsoleInstrumentation::ATTRIBUTE_CONSOLE_EXIT_CODE));
70+
}
71+
72+
public function test_command_run_records_exception(): void
73+
{
74+
$command = new class() extends Command {
75+
protected static $defaultName = 'app:throwing-command';
76+
77+
protected function configure(): void
78+
{
79+
$this->setName('app:throwing-command');
80+
}
81+
82+
protected function execute($input, $output): int
83+
{
84+
throw new \RuntimeException('something went wrong');
85+
}
86+
};
87+
88+
try {
89+
$command->run(new ArrayInput([]), new NullOutput());
90+
$this->fail('Expected exception was not thrown');
91+
} catch (\RuntimeException $e) {
92+
$this->assertSame('something went wrong', $e->getMessage());
93+
}
94+
95+
$this->assertCount(1, $this->storage);
96+
$span = $this->storage[0];
97+
$this->assertSame(StatusCode::STATUS_ERROR, $span->getStatus()->getCode());
98+
$this->assertSame('something went wrong', $span->getStatus()->getDescription());
99+
}
100+
}

0 commit comments

Comments
 (0)