From 1f450e0f64a184112947e22938830dea770df705 Mon Sep 17 00:00:00 2001 From: PuvaanRaaj <112681813+PuvaanRaaj@users.noreply.github.com> Date: Sat, 18 Jul 2026 16:03:16 +0800 Subject: [PATCH] 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-opus-4-8 --- src/Instrumentation/Symfony/_register.php | 2 + src/Instrumentation/Symfony/composer.json | 3 +- .../Symfony/src/ConsoleInstrumentation.php | 95 +++++++++++++++++ .../ConsoleInstrumentationTest.php | 100 ++++++++++++++++++ 4 files changed, 199 insertions(+), 1 deletion(-) create mode 100644 src/Instrumentation/Symfony/src/ConsoleInstrumentation.php create mode 100644 src/Instrumentation/Symfony/tests/Integration/ConsoleInstrumentationTest.php diff --git a/src/Instrumentation/Symfony/_register.php b/src/Instrumentation/Symfony/_register.php index 8fd5767ae..2893f45da 100644 --- a/src/Instrumentation/Symfony/_register.php +++ b/src/Instrumentation/Symfony/_register.php @@ -2,6 +2,7 @@ declare(strict_types=1); +use OpenTelemetry\Contrib\Instrumentation\Symfony\ConsoleInstrumentation; use OpenTelemetry\Contrib\Instrumentation\Symfony\HttpClientInstrumentation; use OpenTelemetry\Contrib\Instrumentation\Symfony\MessengerInstrumentation; use OpenTelemetry\Contrib\Instrumentation\Symfony\SymfonyInstrumentation; @@ -20,3 +21,4 @@ SymfonyInstrumentation::register(); MessengerInstrumentation::register(); HttpClientInstrumentation::register(); +ConsoleInstrumentation::register(); diff --git a/src/Instrumentation/Symfony/composer.json b/src/Instrumentation/Symfony/composer.json index 83e69d0c5..82e53e0b2 100644 --- a/src/Instrumentation/Symfony/composer.json +++ b/src/Instrumentation/Symfony/composer.json @@ -31,7 +31,8 @@ "phpunit/phpunit": "^9.5", "vimeo/psalm": "6.4.0", "symfony/http-client": "^5.4||^6.0", - "symfony/messenger": "^5.4||^6.0" + "symfony/messenger": "^5.4||^6.0", + "symfony/console": "^5.4||^6.0" }, "autoload": { "psr-4": { diff --git a/src/Instrumentation/Symfony/src/ConsoleInstrumentation.php b/src/Instrumentation/Symfony/src/ConsoleInstrumentation.php new file mode 100644 index 000000000..16474593a --- /dev/null +++ b/src/Instrumentation/Symfony/src/ConsoleInstrumentation.php @@ -0,0 +1,95 @@ +url(), + ); + + /** @psalm-suppress UnusedFunctionCall */ + hook( + Command::class, + 'run', + pre: static function ( + Command $command, + array $params, + string $class, + string $function, + ?string $filename, + ?int $lineno, + ) use ($instrumentation): array { + $name = $command->getName() ?? 'command'; + + /** @psalm-suppress ArgumentTypeCoercion */ + $builder = $instrumentation + ->tracer() + ->spanBuilder($name) + ->setSpanKind(SpanKind::KIND_INTERNAL) + ->setAttribute(TraceAttributes::CODE_FUNCTION_NAME, sprintf('%s::%s', $class, $function)) + ->setAttribute(TraceAttributes::CODE_FILE_PATH, $filename) + ->setAttribute(TraceAttributes::CODE_LINE_NUMBER, $lineno); + + $parent = Context::getCurrent(); + $span = $builder + ->setParent($parent) + ->startSpan(); + + Context::storage()->attach($span->storeInContext($parent)); + + return $params; + }, + post: static function ( + Command $command, + array $params, + ?int $returnValue, + ?\Throwable $exception + ): void { + $scope = Context::storage()->scope(); + if (null === $scope) { + return; + } + + $scope->detach(); + $span = Span::fromContext($scope->context()); + + if (null !== $exception) { + $span->recordException($exception); + $span->setStatus(StatusCode::STATUS_ERROR, $exception->getMessage()); + } elseif (null !== $returnValue && $returnValue !== Command::SUCCESS) { + $span->setStatus(StatusCode::STATUS_ERROR); + } + + if (null !== $returnValue) { + $span->setAttribute(self::ATTRIBUTE_CONSOLE_EXIT_CODE, $returnValue); + } + + $span->end(); + } + ); + } +} diff --git a/src/Instrumentation/Symfony/tests/Integration/ConsoleInstrumentationTest.php b/src/Instrumentation/Symfony/tests/Integration/ConsoleInstrumentationTest.php new file mode 100644 index 000000000..caa54273b --- /dev/null +++ b/src/Instrumentation/Symfony/tests/Integration/ConsoleInstrumentationTest.php @@ -0,0 +1,100 @@ +setName('app:test-command'); + } + + protected function execute($input, $output): int + { + return Command::SUCCESS; + } + }; + + $this->assertCount(0, $this->storage); + + $exitCode = $command->run(new ArrayInput([]), new NullOutput()); + + $this->assertSame(Command::SUCCESS, $exitCode); + $this->assertCount(1, $this->storage); + + $span = $this->storage[0]; + $this->assertSame('app:test-command', $span->getName()); + $this->assertSame(SpanKind::KIND_INTERNAL, $span->getKind()); + $this->assertSame(Command::SUCCESS, $span->getAttributes()->get(ConsoleInstrumentation::ATTRIBUTE_CONSOLE_EXIT_CODE)); + $this->assertNotSame(StatusCode::STATUS_ERROR, $span->getStatus()->getCode()); + } + + public function test_command_run_with_non_zero_exit_code_is_error(): void + { + $command = new class() extends Command { + protected static $defaultName = 'app:failing-command'; + + protected function configure(): void + { + $this->setName('app:failing-command'); + } + + protected function execute($input, $output): int + { + return Command::FAILURE; + } + }; + + $exitCode = $command->run(new ArrayInput([]), new NullOutput()); + + $this->assertSame(Command::FAILURE, $exitCode); + $this->assertCount(1, $this->storage); + + $span = $this->storage[0]; + $this->assertSame(StatusCode::STATUS_ERROR, $span->getStatus()->getCode()); + $this->assertSame(Command::FAILURE, $span->getAttributes()->get(ConsoleInstrumentation::ATTRIBUTE_CONSOLE_EXIT_CODE)); + } + + public function test_command_run_records_exception(): void + { + $command = new class() extends Command { + protected static $defaultName = 'app:throwing-command'; + + protected function configure(): void + { + $this->setName('app:throwing-command'); + } + + protected function execute($input, $output): int + { + throw new \RuntimeException('something went wrong'); + } + }; + + try { + $command->run(new ArrayInput([]), new NullOutput()); + $this->fail('Expected exception was not thrown'); + } catch (\RuntimeException $e) { + $this->assertSame('something went wrong', $e->getMessage()); + } + + $this->assertCount(1, $this->storage); + $span = $this->storage[0]; + $this->assertSame(StatusCode::STATUS_ERROR, $span->getStatus()->getCode()); + $this->assertSame('something went wrong', $span->getStatus()->getDescription()); + } +}