diff --git a/src/Instrumentation/Laravel/composer.json b/src/Instrumentation/Laravel/composer.json index 6df3879a..fc143a95 100644 --- a/src/Instrumentation/Laravel/composer.json +++ b/src/Instrumentation/Laravel/composer.json @@ -9,27 +9,28 @@ "minimum-stability": "dev", "require": { "php": "^8.0", - "laravel/framework": ">=6.0", + "ext-json": "*", "ext-opentelemetry": "*", + "laravel/framework": ">=6.0", "open-telemetry/api": "^1.0.0beta10", "open-telemetry/sem-conv": "^1" }, "require-dev": { + "friendsofphp/php-cs-fixer": "^3", + "guzzlehttp/guzzle": "*", + "laravel/sail": "*", "laravel/sanctum": "*", + "laravel/tinker": "*", "nunomaduro/collision": "*", - "friendsofphp/php-cs-fixer": "^3", + "open-telemetry/sdk": "^1.0", "phan/phan": "^5.0", "php-http/mock-client": "*", "phpstan/phpstan": "^1.1", "phpstan/phpstan-phpunit": "^1.0", - "psalm/plugin-phpunit": "^0.16", - "open-telemetry/sdk": "^1.0", "phpunit/phpunit": "^9.5", - "vimeo/psalm": "^4.0", + "psalm/plugin-phpunit": "^0.16", "spatie/laravel-ignition": "*", - "laravel/sail": "*", - "laravel/tinker": "*", - "guzzlehttp/guzzle": "*" + "vimeo/psalm": "^4.0" }, "autoload": { "psr-4": { @@ -46,6 +47,7 @@ } }, "config": { + "sort-packages": true, "allow-plugins": { "php-http/discovery": false } diff --git a/src/Instrumentation/Laravel/src/ConsoleInstrumentation.php b/src/Instrumentation/Laravel/src/ConsoleInstrumentation.php new file mode 100644 index 00000000..05fa0688 --- /dev/null +++ b/src/Instrumentation/Laravel/src/ConsoleInstrumentation.php @@ -0,0 +1,101 @@ +tracer() + ->spanBuilder('Artisan handler') + ->setSpanKind(SpanKind::KIND_PRODUCER) + ->setAttribute(TraceAttributes::CODE_FUNCTION, $function) + ->setAttribute(TraceAttributes::CODE_NAMESPACE, $class) + ->setAttribute(TraceAttributes::CODE_FILEPATH, $filename) + ->setAttribute(TraceAttributes::CODE_LINENO, $lineno); + + $parent = Context::getCurrent(); + $span = $builder->startSpan(); + Context::storage()->attach($span->storeInContext($parent)); + + return $params; + }, + post: static function (Kernel $kernel, array $params, ?int $exitCode, ?Throwable $exception) { + $scope = Context::storage()->scope(); + if (!$scope) { + return; + } + + $scope->detach(); + $span = Span::fromContext($scope->context()); + if ($exception) { + $span->recordException($exception, [TraceAttributes::EXCEPTION_ESCAPED => true]); + $span->setStatus(StatusCode::STATUS_ERROR, $exception->getMessage()); + } elseif ($exitCode !== Command::SUCCESS) { + $span->setStatus(StatusCode::STATUS_ERROR); + } else { + $span->setStatus(StatusCode::STATUS_OK); + } + + $span->end(); + } + ); + + hook( + Command::class, + 'execute', + pre: static function (Command $command, array $params, string $class, string $function, ?string $filename, ?int $lineno) use ($instrumentation) { + /** @psalm-suppress ArgumentTypeCoercion */ + $builder = $instrumentation->tracer() + ->spanBuilder(sprintf('Command %s', $command->getName() ?: 'unknown')) + ->setAttribute(TraceAttributes::CODE_FUNCTION, $function) + ->setAttribute(TraceAttributes::CODE_NAMESPACE, $class) + ->setAttribute(TraceAttributes::CODE_FILEPATH, $filename) + ->setAttribute(TraceAttributes::CODE_LINENO, $lineno); + + $parent = Context::getCurrent(); + $span = $builder->startSpan(); + Context::storage()->attach($span->storeInContext($parent)); + + return $params; + }, + post: static function (Command $command, array $params, ?int $exitCode, ?Throwable $exception) { + $scope = Context::storage()->scope(); + if (!$scope) { + return; + } + + $scope->detach(); + $span = Span::fromContext($scope->context()); + $span->addEvent('command finished', [ + 'exit-code' => $exitCode, + ]); + + if ($exception) { + $span->recordException($exception, [TraceAttributes::EXCEPTION_ESCAPED => true]); + $span->setStatus(StatusCode::STATUS_ERROR, $exception->getMessage()); + } + + $span->end(); + } + ); + } +} diff --git a/src/Instrumentation/Laravel/src/HttpInstrumentation.php b/src/Instrumentation/Laravel/src/HttpInstrumentation.php new file mode 100644 index 00000000..1d5e9291 --- /dev/null +++ b/src/Instrumentation/Laravel/src/HttpInstrumentation.php @@ -0,0 +1,107 @@ +tracer() + ->spanBuilder(sprintf('HTTP %s', $request?->method() ?? 'unknown')) + ->setSpanKind(SpanKind::KIND_SERVER) + ->setAttribute(TraceAttributes::CODE_FUNCTION, $function) + ->setAttribute(TraceAttributes::CODE_NAMESPACE, $class) + ->setAttribute(TraceAttributes::CODE_FILEPATH, $filename) + ->setAttribute(TraceAttributes::CODE_LINENO, $lineno); + $parent = Context::getCurrent(); + if ($request) { + $parent = Globals::propagator()->extract($request, HeadersPropagator::instance()); + $span = $builder + ->setParent($parent) + ->setAttribute(TraceAttributes::HTTP_URL, $request->fullUrl()) + ->setAttribute(TraceAttributes::HTTP_METHOD, $request->method()) + ->setAttribute(TraceAttributes::HTTP_REQUEST_CONTENT_LENGTH, $request->header('Content-Length')) + ->setAttribute(TraceAttributes::HTTP_SCHEME, $request->getScheme()) + ->setAttribute(TraceAttributes::HTTP_FLAVOR, $request->getProtocolVersion()) + ->setAttribute(TraceAttributes::HTTP_CLIENT_IP, $request->ip()) + ->setAttribute(TraceAttributes::HTTP_TARGET, self::httpTarget($request)) + ->setAttribute(TraceAttributes::NET_HOST_NAME, self::httpHostName($request)) + ->setAttribute(TraceAttributes::NET_HOST_PORT, $request->getPort()) + ->setAttribute(TraceAttributes::NET_PEER_PORT, $request->server('REMOTE_PORT')) + ->setAttribute(TraceAttributes::USER_AGENT_ORIGINAL, $request->userAgent()) + ->startSpan(); + $request->attributes->set(SpanInterface::class, $span); + } else { + $span = $builder->startSpan(); + } + Context::storage()->attach($span->storeInContext($parent)); + + return [$request]; + }, + post: static function (Kernel $kernel, array $params, ?Response $response, ?Throwable $exception) { + $scope = Context::storage()->scope(); + if (!$scope) { + return; + } + $scope->detach(); + $span = Span::fromContext($scope->context()); + if ($exception) { + $span->recordException($exception, [TraceAttributes::EXCEPTION_ESCAPED => true]); + $span->setStatus(StatusCode::STATUS_ERROR, $exception->getMessage()); + } + if ($response) { + if ($response->getStatusCode() >= 400) { + $span->setStatus(StatusCode::STATUS_ERROR); + } + $span->setAttribute(TraceAttributes::HTTP_STATUS_CODE, $response->getStatusCode()); + $span->setAttribute(TraceAttributes::HTTP_FLAVOR, $response->getProtocolVersion()); + $span->setAttribute(TraceAttributes::HTTP_RESPONSE_CONTENT_LENGTH, $response->headers->get('Content-Length')); + } + + $span->end(); + } + ); + } + + private static function httpTarget(Request $request): string + { + $query = $request->getQueryString(); + $question = $request->getBaseUrl() . $request->getPathInfo() === '/' ? '/?' : '?'; + + return $query ? $request->path() . $question . $query : $request->path(); + } + + private static function httpHostName(Request $request): string + { + if (method_exists($request, 'host')) { + return $request->host(); + } + if (method_exists($request, 'getHost')) { + return $request->getHost(); + } + + return ''; + } +} diff --git a/src/Instrumentation/Laravel/src/LaravelInstrumentation.php b/src/Instrumentation/Laravel/src/LaravelInstrumentation.php index 5af84c05..cd7ea6ce 100644 --- a/src/Instrumentation/Laravel/src/LaravelInstrumentation.php +++ b/src/Instrumentation/Laravel/src/LaravelInstrumentation.php @@ -4,19 +4,15 @@ namespace OpenTelemetry\Contrib\Instrumentation\Laravel; -use Illuminate\Foundation\Application; -use Illuminate\Foundation\Http\Kernel; -use Illuminate\Http\Request; -use OpenTelemetry\API\Globals; +use Illuminate\Contracts\Foundation\Application; use OpenTelemetry\API\Instrumentation\CachedInstrumentation; -use OpenTelemetry\API\Trace\Span; -use OpenTelemetry\API\Trace\SpanInterface; -use OpenTelemetry\API\Trace\SpanKind; -use OpenTelemetry\API\Trace\StatusCode; -use OpenTelemetry\Context\Context; +use OpenTelemetry\Contrib\Instrumentation\Laravel\Watchers\CacheWatcher; +use OpenTelemetry\Contrib\Instrumentation\Laravel\Watchers\ClientRequestWatcher; +use OpenTelemetry\Contrib\Instrumentation\Laravel\Watchers\ExceptionWatcher; +use OpenTelemetry\Contrib\Instrumentation\Laravel\Watchers\LogWatcher; +use OpenTelemetry\Contrib\Instrumentation\Laravel\Watchers\QueryWatcher; +use OpenTelemetry\Contrib\Instrumentation\Laravel\Watchers\Watcher; use function OpenTelemetry\Instrumentation\hook; -use OpenTelemetry\SemConv\TraceAttributes; -use Symfony\Component\HttpFoundation\Response; use Throwable; class LaravelInstrumentation @@ -31,101 +27,20 @@ public static function registerWatchers(Application $app, Watcher $watcher) public static function register(): void { $instrumentation = new CachedInstrumentation('io.opentelemetry.contrib.php.laravel'); - hook( - Kernel::class, - 'handle', - pre: static function (Kernel $kernel, array $params, string $class, string $function, ?string $filename, ?int $lineno) use ($instrumentation) { - $request = ($params[0] instanceof Request) ? $params[0] : null; - /** @psalm-suppress ArgumentTypeCoercion */ - $builder = $instrumentation->tracer() - ->spanBuilder(sprintf('HTTP %s', $request?->method() ?? 'unknown')) - ->setSpanKind(SpanKind::KIND_SERVER) - ->setAttribute(TraceAttributes::CODE_FUNCTION, $function) - ->setAttribute(TraceAttributes::CODE_NAMESPACE, $class) - ->setAttribute(TraceAttributes::CODE_FILEPATH, $filename) - ->setAttribute(TraceAttributes::CODE_LINENO, $lineno); - $parent = Context::getCurrent(); - if ($request) { - $parent = Globals::propagator()->extract($request, HeadersPropagator::instance()); - $span = $builder - ->setParent($parent) - ->setAttribute(TraceAttributes::HTTP_URL, $request->fullUrl()) - ->setAttribute(TraceAttributes::HTTP_METHOD, $request->method()) - ->setAttribute(TraceAttributes::HTTP_REQUEST_CONTENT_LENGTH, $request->header('Content-Length')) - ->setAttribute(TraceAttributes::HTTP_SCHEME, $request->getScheme()) - ->setAttribute(TraceAttributes::HTTP_FLAVOR, $request->getProtocolVersion()) - ->setAttribute(TraceAttributes::HTTP_CLIENT_IP, $request->ip()) - ->setAttribute(TraceAttributes::HTTP_TARGET, self::httpTarget($request)) - ->setAttribute(TraceAttributes::NET_HOST_NAME, self::httpHostName($request)) - ->setAttribute(TraceAttributes::NET_HOST_PORT, $request->getPort()) - ->setAttribute(TraceAttributes::NET_PEER_PORT, $request->server('REMOTE_PORT')) - ->setAttribute(TraceAttributes::USER_AGENT_ORIGINAL, $request->userAgent()) - ->startSpan(); - $request->attributes->set(SpanInterface::class, $span); - } else { - $span = $builder->startSpan(); - } - Context::storage()->attach($span->storeInContext($parent)); - - return [$request]; - }, - post: static function (Kernel $kernel, array $params, ?Response $response, ?Throwable $exception) { - $scope = Context::storage()->scope(); - if (!$scope) { - return; - } - $scope->detach(); - $span = Span::fromContext($scope->context()); - if ($exception) { - $span->recordException($exception, [TraceAttributes::EXCEPTION_ESCAPED => true]); - $span->setStatus(StatusCode::STATUS_ERROR, $exception->getMessage()); - } - if ($response) { - if ($response->getStatusCode() >= 400) { - $span->setStatus(StatusCode::STATUS_ERROR); - } - $span->setAttribute(TraceAttributes::HTTP_STATUS_CODE, $response->getStatusCode()); - $span->setAttribute(TraceAttributes::HTTP_FLAVOR, $response->getProtocolVersion()); - $span->setAttribute(TraceAttributes::HTTP_RESPONSE_CONTENT_LENGTH, $response->headers->get('Content-Length')); - } - $span->end(); - } - ); hook( - Kernel::class, + Application::class, '__construct', - pre: static function (Kernel $kernel, array $params, string $class, string $function, ?string $filename, ?int $lineno) use ($instrumentation) { - $app = $params[0]; - $app->booted(static function (Application $app) use ($instrumentation) { - self::registerWatchers($app, new ClientRequestWatcher($instrumentation)); - self::registerWatchers($app, new ExceptionWatcher()); - self::registerWatchers($app, new CacheWatcher()); - self::registerWatchers($app, new LogWatcher()); - self::registerWatchers($app, new QueryWatcher($instrumentation)); - }); + post: static function (Application $application, array $params, mixed $returnValue, ?Throwable $exception) use ($instrumentation) { + self::registerWatchers($application, new CacheWatcher()); + self::registerWatchers($application, new ClientRequestWatcher($instrumentation)); + self::registerWatchers($application, new ExceptionWatcher()); + self::registerWatchers($application, new LogWatcher()); + self::registerWatchers($application, new QueryWatcher($instrumentation)); }, - post: null ); - } - - private static function httpTarget(Request $request): string - { - $query = $request->getQueryString(); - $question = $request->getBaseUrl() . $request->getPathInfo() === '/' ? '/?' : '?'; - - return $query ? $request->path() . $question . $query : $request->path(); - } - - private static function httpHostName(Request $request): string - { - if (method_exists($request, 'host')) { - return $request->host(); - } - if (method_exists($request, 'getHost')) { - return $request->getHost(); - } - return ''; + ConsoleInstrumentation::register($instrumentation); + HttpInstrumentation::register($instrumentation); } } diff --git a/src/Instrumentation/Laravel/src/CacheWatcher.php b/src/Instrumentation/Laravel/src/Watchers/CacheWatcher.php similarity index 97% rename from src/Instrumentation/Laravel/src/CacheWatcher.php rename to src/Instrumentation/Laravel/src/Watchers/CacheWatcher.php index b3daf710..d4761f41 100644 --- a/src/Instrumentation/Laravel/src/CacheWatcher.php +++ b/src/Instrumentation/Laravel/src/Watchers/CacheWatcher.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace OpenTelemetry\Contrib\Instrumentation\Laravel; +namespace OpenTelemetry\Contrib\Instrumentation\Laravel\Watchers; use Illuminate\Cache\Events\CacheHit; use Illuminate\Cache\Events\CacheMissed; diff --git a/src/Instrumentation/Laravel/src/ClientRequestWatcher.php b/src/Instrumentation/Laravel/src/Watchers/ClientRequestWatcher.php similarity index 98% rename from src/Instrumentation/Laravel/src/ClientRequestWatcher.php rename to src/Instrumentation/Laravel/src/Watchers/ClientRequestWatcher.php index f7db97f7..6e2226f5 100644 --- a/src/Instrumentation/Laravel/src/ClientRequestWatcher.php +++ b/src/Instrumentation/Laravel/src/Watchers/ClientRequestWatcher.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace OpenTelemetry\Contrib\Instrumentation\Laravel; +namespace OpenTelemetry\Contrib\Instrumentation\Laravel\Watchers; use Illuminate\Contracts\Foundation\Application; use Illuminate\Http\Client\Events\ConnectionFailed; diff --git a/src/Instrumentation/Laravel/src/ExceptionWatcher.php b/src/Instrumentation/Laravel/src/Watchers/ExceptionWatcher.php similarity index 95% rename from src/Instrumentation/Laravel/src/ExceptionWatcher.php rename to src/Instrumentation/Laravel/src/Watchers/ExceptionWatcher.php index 4ce20ed4..31bdc00d 100644 --- a/src/Instrumentation/Laravel/src/ExceptionWatcher.php +++ b/src/Instrumentation/Laravel/src/Watchers/ExceptionWatcher.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace OpenTelemetry\Contrib\Instrumentation\Laravel; +namespace OpenTelemetry\Contrib\Instrumentation\Laravel\Watchers; use Illuminate\Contracts\Foundation\Application; use Illuminate\Log\Events\MessageLogged; diff --git a/src/Instrumentation/Laravel/src/LogWatcher.php b/src/Instrumentation/Laravel/src/Watchers/LogWatcher.php similarity index 93% rename from src/Instrumentation/Laravel/src/LogWatcher.php rename to src/Instrumentation/Laravel/src/Watchers/LogWatcher.php index 85cd4dbc..370b501d 100644 --- a/src/Instrumentation/Laravel/src/LogWatcher.php +++ b/src/Instrumentation/Laravel/src/Watchers/LogWatcher.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace OpenTelemetry\Contrib\Instrumentation\Laravel; +namespace OpenTelemetry\Contrib\Instrumentation\Laravel\Watchers; use Illuminate\Contracts\Foundation\Application; use Illuminate\Log\Events\MessageLogged; diff --git a/src/Instrumentation/Laravel/src/QueryWatcher.php b/src/Instrumentation/Laravel/src/Watchers/QueryWatcher.php similarity index 97% rename from src/Instrumentation/Laravel/src/QueryWatcher.php rename to src/Instrumentation/Laravel/src/Watchers/QueryWatcher.php index c3f0347b..eed90c1e 100644 --- a/src/Instrumentation/Laravel/src/QueryWatcher.php +++ b/src/Instrumentation/Laravel/src/Watchers/QueryWatcher.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace OpenTelemetry\Contrib\Instrumentation\Laravel; +namespace OpenTelemetry\Contrib\Instrumentation\Laravel\Watchers; use Illuminate\Contracts\Foundation\Application; use Illuminate\Database\Events\QueryExecuted; diff --git a/src/Instrumentation/Laravel/src/Watcher.php b/src/Instrumentation/Laravel/src/Watchers/Watcher.php similarity index 76% rename from src/Instrumentation/Laravel/src/Watcher.php rename to src/Instrumentation/Laravel/src/Watchers/Watcher.php index 759a9bc8..67540bae 100644 --- a/src/Instrumentation/Laravel/src/Watcher.php +++ b/src/Instrumentation/Laravel/src/Watchers/Watcher.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace OpenTelemetry\Contrib\Instrumentation\Laravel; +namespace OpenTelemetry\Contrib\Instrumentation\Laravel\Watchers; use Illuminate\Contracts\Foundation\Application; diff --git a/src/Instrumentation/Laravel/tests/Integration/ConsoleInstrumentationTest.php b/src/Instrumentation/Laravel/tests/Integration/ConsoleInstrumentationTest.php new file mode 100644 index 00000000..c0aab5bc --- /dev/null +++ b/src/Instrumentation/Laravel/tests/Integration/ConsoleInstrumentationTest.php @@ -0,0 +1,75 @@ +storage = new ArrayObject(); + $tracerProvider = new TracerProvider( + new SimpleSpanProcessor( + new InMemoryExporter($this->storage) + ) + ); + + $this->scope = Configurator::create() + ->withTracerProvider($tracerProvider) + ->activate(); + } + + public function tearDown(): void + { + $this->scope->detach(); + parent::tearDown(); + } + + public function test_command_tracing(): void + { + $this->assertCount(0, $this->storage); + + /** @var Kernel $kernel */ + $kernel = $this->app[Kernel::class]; + $exitCode = $kernel->handle( + new \Symfony\Component\Console\Input\ArrayInput(['optimize:clear']), + new \Symfony\Component\Console\Output\NullOutput(), + ); + + $this->assertEquals(Command::SUCCESS, $exitCode); + + /** + * The storage appends spans as they are marked as ended. eg: `$span->end()`. + * So in this test, `optimize:clear` calls additional commands which complete first + * and thus appear in the stack ahead of it. + * + * @see \Illuminate\Foundation\Console\OptimizeClearCommand::handle() for the additional commands/spans. + */ + $count = 8; + $this->assertCount($count, $this->storage); + + /** @var ImmutableSpan $span */ + $span = $this->storage->offsetGet(--$count); + $this->assertSame('Artisan handler', $span->getName()); + + $span = $this->storage->offsetGet(--$count); + $this->assertSame('Command optimize:clear', $span->getName()); + } +} diff --git a/src/Instrumentation/Laravel/tests/Integration/LaravelInstrumentationTest.php b/src/Instrumentation/Laravel/tests/Integration/LaravelInstrumentationTest.php index 978cf9f2..f98653a9 100644 --- a/src/Instrumentation/Laravel/tests/Integration/LaravelInstrumentationTest.php +++ b/src/Instrumentation/Laravel/tests/Integration/LaravelInstrumentationTest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace OpenTelemetry\Tests\Instrumentation\Laravel\tests\Integration; +namespace OpenTelemetry\Tests\Instrumentation\Laravel\Integration; use ArrayObject; use Illuminate\Foundation\Http\Kernel; @@ -44,6 +44,8 @@ public function setUp(): void $this->scope = Configurator::create() ->withTracerProvider($this->tracerProvider) ->activate(); + + Http::fake(); } public function tearDown(): void