diff --git a/src/Instrumentation/Laravel/.gitattributes b/src/Instrumentation/Laravel/.gitattributes index 1676cf82..ac40e9f8 100644 --- a/src/Instrumentation/Laravel/.gitattributes +++ b/src/Instrumentation/Laravel/.gitattributes @@ -5,6 +5,7 @@ /.gitattributes export-ignore /.gitignore export-ignore +/.phan export-ignore /.php-cs-fixer.php export-ignore /phpstan.neon.dist export-ignore /phpunit.xml.dist export-ignore diff --git a/src/Instrumentation/Laravel/.php-cs-fixer.php b/src/Instrumentation/Laravel/.php-cs-fixer.php index e35fa078..bc482805 100644 --- a/src/Instrumentation/Laravel/.php-cs-fixer.php +++ b/src/Instrumentation/Laravel/.php-cs-fixer.php @@ -40,4 +40,3 @@ ]) ->setRiskyAllowed(true) ->setFinder($finder); - diff --git a/src/Instrumentation/Laravel/composer.json b/src/Instrumentation/Laravel/composer.json index df309cbf..65372b3b 100644 --- a/src/Instrumentation/Laravel/composer.json +++ b/src/Instrumentation/Laravel/composer.json @@ -7,21 +7,21 @@ "readme": "./README.md", "license": "Apache-2.0", "minimum-stability": "dev", + "prefer-stable": true, "require": { "php": "^8.0", "ext-json": "*", "ext-opentelemetry": "*", - "laravel/framework": ">=6.0", + "laravel/framework": "^6.0 || ^7.0 || ^8.0 || ^9.0 || ^10.0 || ^11.0", "open-telemetry/api": "^1.0", - "open-telemetry/sem-conv": "^1.23" + "open-telemetry/sem-conv": "^1.24" }, "require-dev": { - "friendsofphp/php-cs-fixer": "^3", + "friendsofphp/php-cs-fixer": "^3.50", "guzzlehttp/guzzle": "*", - "laravel/tinker": "*", "nunomaduro/collision": "*", "open-telemetry/sdk": "^1.0", - "orchestra/testbench": ">=4.0", + "orchestra/testbench": ">=7.41.3", "phan/phan": "^5.0", "php-http/mock-client": "*", "phpstan/phpstan": "^1.1", diff --git a/src/Instrumentation/Laravel/src/ConsoleInstrumentation.php b/src/Instrumentation/Laravel/src/ConsoleInstrumentation.php deleted file mode 100644 index 94362de0..00000000 --- a/src/Instrumentation/Laravel/src/ConsoleInstrumentation.php +++ /dev/null @@ -1,119 +0,0 @@ -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(); - } - ); - } - - private static function hookCommandExecution(CachedInstrumentation $instrumentation): void - { - 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/Hooks/Illuminate/Console/Command.php b/src/Instrumentation/Laravel/src/Hooks/Illuminate/Console/Command.php new file mode 100644 index 00000000..d4515390 --- /dev/null +++ b/src/Instrumentation/Laravel/src/Hooks/Illuminate/Console/Command.php @@ -0,0 +1,63 @@ +hookExecute(); + } + + protected function hookExecute(): bool + { + return hook( + IlluminateCommand::class, + 'execute', + pre: function (IlluminateCommand $command, array $params, string $class, string $function, ?string $filename, ?int $lineno) { + /** @psalm-suppress ArgumentTypeCoercion */ + $builder = $this->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: function (IlluminateCommand $command, array $params, ?int $exitCode, ?Throwable $exception) { + $scope = Context::storage()->scope(); + if (!$scope) { + return; + } + + $span = Span::fromContext($scope->context()); + $span->addEvent('command finished', [ + 'exit-code' => $exitCode, + ]); + + $this->endSpan($exception); + } + ); + } +} diff --git a/src/Instrumentation/Laravel/src/Hooks/Illuminate/Contracts/Console/Kernel.php b/src/Instrumentation/Laravel/src/Hooks/Illuminate/Contracts/Console/Kernel.php new file mode 100644 index 00000000..edf961f9 --- /dev/null +++ b/src/Instrumentation/Laravel/src/Hooks/Illuminate/Contracts/Console/Kernel.php @@ -0,0 +1,73 @@ +hookHandle(); + } + } + + private function hookHandle(): bool + { + return hook( + KernelContract::class, + 'handle', + pre: function (KernelContract $kernel, array $params, string $class, string $function, ?string $filename, ?int $lineno) { + /** @psalm-suppress ArgumentTypeCoercion */ + $builder = $this->instrumentation + ->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: function (KernelContract $kernel, array $params, ?int $exitCode, ?Throwable $exception) { + $scope = Context::storage()->scope(); + if (!$scope) { + return; + } + + $span = Span::fromContext($scope->context()); + + if ($exitCode !== Command::SUCCESS) { + $span->setStatus(StatusCode::STATUS_ERROR); + } + + $this->endSpan($exception); + } + ); + } +} diff --git a/src/Instrumentation/Laravel/src/HttpInstrumentation.php b/src/Instrumentation/Laravel/src/Hooks/Illuminate/Contracts/Http/Kernel.php similarity index 71% rename from src/Instrumentation/Laravel/src/HttpInstrumentation.php rename to src/Instrumentation/Laravel/src/Hooks/Illuminate/Contracts/Http/Kernel.php index 8efbe962..e9e35358 100644 --- a/src/Instrumentation/Laravel/src/HttpInstrumentation.php +++ b/src/Instrumentation/Laravel/src/Hooks/Illuminate/Contracts/Http/Kernel.php @@ -2,33 +2,47 @@ declare(strict_types=1); -namespace OpenTelemetry\Contrib\Instrumentation\Laravel; +namespace OpenTelemetry\Contrib\Instrumentation\Laravel\Hooks\Illuminate\Contracts\Http; -use Illuminate\Contracts\Http\Kernel; +use Illuminate\Contracts\Http\Kernel as KernelContract; use Illuminate\Http\Request; +use Illuminate\Routing\Route; use OpenTelemetry\API\Globals; -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\Hooks\LaravelHook; +use OpenTelemetry\Contrib\Instrumentation\Laravel\Hooks\LaravelHookTrait; +use OpenTelemetry\Contrib\Instrumentation\Laravel\Hooks\PostHookTrait; +use OpenTelemetry\Contrib\Instrumentation\Laravel\Propagators\HeadersPropagator; +use OpenTelemetry\Contrib\Instrumentation\Laravel\Propagators\ResponsePropagationSetter; use function OpenTelemetry\Instrumentation\hook; use OpenTelemetry\SemConv\TraceAttributes; use Symfony\Component\HttpFoundation\Response; use Throwable; -class HttpInstrumentation +class Kernel implements LaravelHook { - public static function register(CachedInstrumentation $instrumentation): void + use LaravelHookTrait; + use PostHookTrait; + + public function instrument(): void + { + $this->hookHandle(); + } + + protected function hookHandle(): bool { - hook( - Kernel::class, + return hook( + KernelContract::class, 'handle', - pre: static function (Kernel $kernel, array $params, string $class, string $function, ?string $filename, ?int $lineno) use ($instrumentation) { + pre: function (KernelContract $kernel, array $params, string $class, string $function, ?string $filename, ?int $lineno) { $request = ($params[0] instanceof Request) ? $params[0] : null; /** @psalm-suppress ArgumentTypeCoercion */ - $builder = $instrumentation->tracer() + $builder = $this->instrumentation + ->tracer() ->spanBuilder(sprintf('%s', $request?->method() ?? 'unknown')) ->setSpanKind(SpanKind::KIND_SERVER) ->setAttribute(TraceAttributes::CODE_FUNCTION, $function) @@ -37,6 +51,7 @@ public static function register(CachedInstrumentation $instrumentation): void ->setAttribute(TraceAttributes::CODE_LINENO, $lineno); $parent = Context::getCurrent(); if ($request) { + /** @phan-suppress-next-line PhanAccessMethodInternal */ $parent = Globals::propagator()->extract($request, HeadersPropagator::instance()); $span = $builder ->setParent($parent) @@ -46,8 +61,8 @@ public static function register(CachedInstrumentation $instrumentation): void ->setAttribute(TraceAttributes::URL_SCHEME, $request->getScheme()) ->setAttribute(TraceAttributes::NETWORK_PROTOCOL_VERSION, $request->getProtocolVersion()) ->setAttribute(TraceAttributes::NETWORK_PEER_ADDRESS, $request->ip()) - ->setAttribute(TraceAttributes::URL_PATH, self::httpTarget($request)) - ->setAttribute(TraceAttributes::SERVER_ADDRESS, self::httpHostName($request)) + ->setAttribute(TraceAttributes::URL_PATH, $this->httpTarget($request)) + ->setAttribute(TraceAttributes::SERVER_ADDRESS, $this->httpHostName($request)) ->setAttribute(TraceAttributes::SERVER_PORT, $request->getPort()) ->setAttribute(TraceAttributes::CLIENT_PORT, $request->server('REMOTE_PORT')) ->setAttribute(TraceAttributes::USER_AGENT_ORIGINAL, $request->userAgent()) @@ -60,17 +75,21 @@ public static function register(CachedInstrumentation $instrumentation): void return [$request]; }, - post: static function (Kernel $kernel, array $params, ?Response $response, ?Throwable $exception) { + post: function (KernelContract $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()); + + $request = ($params[0] instanceof Request) ? $params[0] : null; + $route = $request?->route(); + + if ($request && $route instanceof Route) { + $span->updateName("{$request->method()} /" . ltrim($route->uri, '/')); + $span->setAttribute(TraceAttributes::HTTP_ROUTE, $route->uri); } + if ($response) { if ($response->getStatusCode() >= 400) { $span->setStatus(StatusCode::STATUS_ERROR); @@ -83,7 +102,7 @@ public static function register(CachedInstrumentation $instrumentation): void if (class_exists('OpenTelemetry\Contrib\Propagation\ServerTiming\ServerTimingPropagator')) { /** @phan-suppress-next-line PhanUndeclaredClassMethod */ $prop = new \OpenTelemetry\Contrib\Propagation\ServerTiming\ServerTimingPropagator(); - /** @phan-suppress-next-line PhanUndeclaredClassMethod */ + /** @phan-suppress-next-line PhanAccessMethodInternal,PhanUndeclaredClassMethod */ $prop->inject($response, ResponsePropagationSetter::instance(), $scope->context()); } @@ -91,17 +110,17 @@ public static function register(CachedInstrumentation $instrumentation): void if (class_exists('OpenTelemetry\Contrib\Propagation\TraceResponse\TraceResponsePropagator')) { /** @phan-suppress-next-line PhanUndeclaredClassMethod */ $prop = new \OpenTelemetry\Contrib\Propagation\TraceResponse\TraceResponsePropagator(); - /** @phan-suppress-next-line PhanUndeclaredClassMethod */ + /** @phan-suppress-next-line PhanAccessMethodInternal,PhanUndeclaredClassMethod */ $prop->inject($response, ResponsePropagationSetter::instance(), $scope->context()); } } - $span->end(); + $this->endSpan($exception); } ); } - private static function httpTarget(Request $request): string + private function httpTarget(Request $request): string { $query = $request->getQueryString(); $question = $request->getBaseUrl() . $request->getPathInfo() === '/' ? '/?' : '?'; @@ -109,11 +128,12 @@ private static function httpTarget(Request $request): string return $query ? $request->path() . $question . $query : $request->path(); } - private static function httpHostName(Request $request): string + private function httpHostName(Request $request): string { if (method_exists($request, 'host')) { return $request->host(); } + if (method_exists($request, 'getHost')) { return $request->getHost(); } diff --git a/src/Instrumentation/Laravel/src/Hooks/Illuminate/Contracts/Queue/Queue.php b/src/Instrumentation/Laravel/src/Hooks/Illuminate/Contracts/Queue/Queue.php new file mode 100644 index 00000000..c58800db --- /dev/null +++ b/src/Instrumentation/Laravel/src/Hooks/Illuminate/Contracts/Queue/Queue.php @@ -0,0 +1,143 @@ +hookBulk(); + $this->hookLater(); + $this->hookPushRaw(); + } + + protected function hookBulk(): bool + { + return hook( + QueueContract::class, + 'bulk', + pre: function (QueueContract $queue, array $params, string $class, string $function, ?string $filename, ?int $lineno) { + $attributes = array_merge([ + TraceAttributes::CODE_FUNCTION => $function, + TraceAttributes::CODE_NAMESPACE => $class, + TraceAttributes::CODE_FILEPATH => $filename, + TraceAttributes::CODE_LINENO => $lineno, + TraceAttributes::MESSAGING_BATCH_MESSAGE_COUNT => count($params[0] ?? []), + ], $this->contextualMessageSystemAttributes($queue, [])); + + /** @psalm-suppress ArgumentTypeCoercion */ + $span = $this->instrumentation + ->tracer() + ->spanBuilder(vsprintf('%s %s', [ + /** @phan-suppress-next-line PhanUndeclaredMethod */ + method_exists($queue, 'getQueue') ? $queue->getQueue($params[2] ?? null) : $queue->getConnectionName(), + TraceAttributeValues::MESSAGING_OPERATION_PUBLISH, + ])) + ->setSpanKind(SpanKind::KIND_PRODUCER) + ->setAttributes($attributes) + ->startSpan(); + + Context::storage()->attach($span->storeInContext(Context::getCurrent())); + + return $params; + }, + post: function (QueueContract $queue, array $params, $returnValue, ?Throwable $exception) { + $this->endSpan($exception); + }, + ); + } + + protected function hookLater(): bool + { + return hook( + QueueContract::class, + 'later', + pre: function (QueueContract $queue, array $params, string $class, string $function, ?string $filename, ?int $lineno) { + $estimateDeliveryTimestamp = match (true) { + is_int($params[0]) => (new \DateTimeImmutable())->add(new DateInterval("PT{$params[0]}S"))->getTimestamp(), + $params[0] instanceof DateInterval => (new \DateTimeImmutable())->add($params[0])->getTimestamp(), + $params[0] instanceof DateTimeInterface => ($params[0])->getTimestamp(), + default => $params[0], + }; + + $attributes = [ + TraceAttributes::CODE_FUNCTION => $function, + TraceAttributes::CODE_NAMESPACE => $class, + TraceAttributes::CODE_FILEPATH => $filename, + TraceAttributes::CODE_LINENO => $lineno, + 'messaging.message.delivery_timestamp' => $estimateDeliveryTimestamp, + ]; + + /** @psalm-suppress ArgumentTypeCoercion */ + $span = $this->instrumentation + ->tracer() + ->spanBuilder(vsprintf('%s %s', [ + /** @phan-suppress-next-line PhanUndeclaredMethod */ + method_exists($queue, 'getQueue') ? $queue->getQueue($params[2] ?? null) : $queue->getConnectionName(), + 'create', + ])) + ->setSpanKind(SpanKind::KIND_PRODUCER) + ->setAttributes($attributes) + ->startSpan(); + + Context::storage()->attach($span->storeInContext(Context::getCurrent())); + + return $params; + }, + post: function (QueueContract $queue, array $params, $returnValue, ?Throwable $exception) { + $this->endSpan($exception); + }, + ); + } + + protected function hookPushRaw(): bool + { + return hook( + QueueContract::class, + 'pushRaw', + pre: function (QueueContract $queue, array $params, string $class, string $function, ?string $filename, ?int $lineno) { + /** @phan-suppress-next-line PhanParamTooFewUnpack */ + $attributes = $this->buildMessageAttributes($queue, ...$params); + + $parent = Context::getCurrent(); + /** @psalm-suppress ArgumentTypeCoercion */ + $span = $this->instrumentation + ->tracer() + ->spanBuilder(vsprintf('%s %s', [ + $attributes[TraceAttributes::MESSAGING_DESTINATION_NAME], + TraceAttributeValues::MESSAGING_OPERATION_CREATE, + ])) + ->setSpanKind(SpanKind::KIND_PRODUCER) + ->setAttributes($attributes) + ->startSpan(); + + Context::storage()->attach($span->storeInContext($parent)); + + return $params; + }, + post: function (QueueContract $queue, array $params, $returnValue, ?Throwable $exception) { + $this->endSpan($exception); + }, + ); + } +} diff --git a/src/Instrumentation/Laravel/src/Hooks/Illuminate/Foundation/Application.php b/src/Instrumentation/Laravel/src/Hooks/Illuminate/Foundation/Application.php new file mode 100644 index 00000000..f76b2986 --- /dev/null +++ b/src/Instrumentation/Laravel/src/Hooks/Illuminate/Foundation/Application.php @@ -0,0 +1,43 @@ +registerWatchers($application, new CacheWatcher()); + $this->registerWatchers($application, new ClientRequestWatcher($this->instrumentation)); + $this->registerWatchers($application, new ExceptionWatcher()); + $this->registerWatchers($application, new LogWatcher()); + $this->registerWatchers($application, new QueryWatcher($this->instrumentation)); + }, + ); + } + + private function registerWatchers(ApplicationContract $app, Watcher $watcher): void + { + $watcher->register($app); + } +} diff --git a/src/Instrumentation/Laravel/src/Hooks/Illuminate/Foundation/Console/ServeCommand.php b/src/Instrumentation/Laravel/src/Hooks/Illuminate/Foundation/Console/ServeCommand.php new file mode 100644 index 00000000..190113d8 --- /dev/null +++ b/src/Instrumentation/Laravel/src/Hooks/Illuminate/Foundation/Console/ServeCommand.php @@ -0,0 +1,37 @@ + $value) { + if (str_starts_with($key, 'OTEL_') && !in_array($key, FoundationServeCommand::$passthroughVariables)) { + FoundationServeCommand::$passthroughVariables[] = $key; + } + } + }, + ); + } +} diff --git a/src/Instrumentation/Laravel/src/Hooks/Illuminate/Queue/AttributesBuilder.php b/src/Instrumentation/Laravel/src/Hooks/Illuminate/Queue/AttributesBuilder.php new file mode 100644 index 00000000..c005174b --- /dev/null +++ b/src/Instrumentation/Laravel/src/Hooks/Illuminate/Queue/AttributesBuilder.php @@ -0,0 +1,76 @@ + '(anonymous)', + TraceAttributes::MESSAGING_MESSAGE_ID => $payload['uuid'] ?? $payload['id'] ?? null, + TraceAttributes::MESSAGING_MESSAGE_ENVELOPE_SIZE => strlen($rawPayload), + 'messaging.message.job_name' => $payload['displayName'] ?? $payload['job'] ?? null, + 'messaging.message.attempts' => $payload['attempts'] ?? 0, + 'messaging.message.max_exceptions' => $payload['maxExceptions'] ?? null, + 'messaging.message.max_tries' => $payload['maxTries'] ?? null, + 'messaging.message.retry_until' => $payload['retryUntil'] ?? null, + 'messaging.message.timeout' => $payload['timeout'] ?? null, + ], $this->contextualMessageSystemAttributes($queue, $payload, $queueName, $options, ...$params)); + } + + private function contextualMessageSystemAttributes( + QueueContract $queue, + array $payload, + string $queueName = null, + array $options = [], + mixed ...$params, + ): array { + return match (true) { + $queue instanceof BeanstalkdQueue => $this->beanstalkContextualAttributes($queue, $payload, $queueName, $options, ...$params), + $queue instanceof RedisQueue => $this->redisContextualAttributes($queue, $payload, $queueName, $options, ...$params), + $queue instanceof SqsQueue => $this->awsSqsContextualAttributes($queue, $payload, $queueName, $options, ...$params), + default => [], + }; + } + + private function beanstalkContextualAttributes(BeanstalkdQueue $queue, array $payload, string $queueName = null, array $options = [], mixed ...$params): array + { + return [ + TraceAttributes::MESSAGING_SYSTEM => 'beanstalk', + TraceAttributes::MESSAGING_DESTINATION_NAME => $queue->getQueue($queueName), + ]; + } + + private function redisContextualAttributes(RedisQueue $queue, array $payload, string $queueName = null, array $options = [], mixed ...$params): array + { + return [ + TraceAttributes::MESSAGING_SYSTEM => 'redis', + TraceAttributes::MESSAGING_DESTINATION_NAME => $queue->getQueue($queueName), + ]; + } + + private function awsSqsContextualAttributes(SqsQueue $queue, array $payload, string $queueName = null, array $options = [], mixed ...$params): array + { + return [ + TraceAttributes::MESSAGING_SYSTEM => TraceAttributeValues::MESSAGING_SYSTEM_AWS_SQS, + TraceAttributes::MESSAGING_DESTINATION_NAME => $queue->getQueue($queueName), + ]; + } +} diff --git a/src/Instrumentation/Laravel/src/Hooks/Illuminate/Queue/Queue.php b/src/Instrumentation/Laravel/src/Hooks/Illuminate/Queue/Queue.php new file mode 100644 index 00000000..b1a0c7aa --- /dev/null +++ b/src/Instrumentation/Laravel/src/Hooks/Illuminate/Queue/Queue.php @@ -0,0 +1,36 @@ +hookAbstractQueueCreatePayloadArray(); + } + + protected function hookAbstractQueueCreatePayloadArray(): bool + { + return hook( + AbstractQueue::class, + 'createPayloadArray', + post: function (AbstractQueue $queue, array $params, array $payload, ?Throwable $exception): array { + TraceContextPropagator::getInstance()->inject($payload); + + return $payload; + }, + ); + } +} diff --git a/src/Instrumentation/Laravel/src/Hooks/Illuminate/Queue/SyncQueue.php b/src/Instrumentation/Laravel/src/Hooks/Illuminate/Queue/SyncQueue.php new file mode 100644 index 00000000..19d85cc2 --- /dev/null +++ b/src/Instrumentation/Laravel/src/Hooks/Illuminate/Queue/SyncQueue.php @@ -0,0 +1,57 @@ +hookPush(); + } + + protected function hookPush(): bool + { + return hook( + LaravelSyncQueue::class, + 'push', + pre: function (LaravelSyncQueue $queue, array $params, string $class, string $function, ?string $filename, ?int $lineno) { + /** @psalm-suppress ArgumentTypeCoercion */ + $span = $this->instrumentation + ->tracer() + ->spanBuilder(vsprintf('%s %s', [ + $queue->getConnectionName(), + 'process', + ])) + ->setSpanKind(SpanKind::KIND_INTERNAL) + ->setAttributes([ + TraceAttributes::CODE_FUNCTION => $function, + TraceAttributes::CODE_NAMESPACE => $class, + TraceAttributes::CODE_FILEPATH => $filename, + TraceAttributes::CODE_LINENO => $lineno, + ]) + ->startSpan(); + + Context::storage()->attach($span->storeInContext(Context::getCurrent())); + }, + post: function (LaravelSyncQueue $queue, array $params, mixed $returnValue, ?Throwable $exception) { + $this->endSpan($exception); + }, + ); + } +} diff --git a/src/Instrumentation/Laravel/src/Hooks/Illuminate/Queue/Worker.php b/src/Instrumentation/Laravel/src/Hooks/Illuminate/Queue/Worker.php new file mode 100644 index 00000000..0e295a69 --- /dev/null +++ b/src/Instrumentation/Laravel/src/Hooks/Illuminate/Queue/Worker.php @@ -0,0 +1,139 @@ +hookWorkerProcess(); + $this->hookWorkerGetNextJob(); + } + + private function hookWorkerProcess(): bool + { + return hook( + QueueWorker::class, + 'process', + pre: function (QueueWorker $worker, array $params, string $class, string $function, ?string $filename, ?int $lineno) { + $connectionName = $params[0]; + /** @var Job $job */ + $job = $params[1]; + + $parent = TraceContextPropagator::getInstance()->extract( + $job->payload(), + ); + + $queue = $worker->getManager()->connection($connectionName); + $attributes = $this->buildMessageAttributes($queue, $job->getRawBody(), $job->getQueue()); + + /** @psalm-suppress ArgumentTypeCoercion */ + $span = $this->instrumentation + ->tracer() + ->spanBuilder(vsprintf('%s %s', [ + $attributes[TraceAttributes::MESSAGING_DESTINATION_NAME], + 'process', + ])) + ->setSpanKind(SpanKind::KIND_CONSUMER) + ->setParent($parent) + ->setAttributes($attributes) + ->startSpan(); + + Context::storage()->attach($span->storeInContext($parent)); + + return $params; + }, + post: function (QueueWorker $worker, array $params, $returnValue, ?Throwable $exception) { + $scope = Context::storage()->scope(); + if (!$scope) { + return; + } + + $span = Span::fromContext($scope->context()); + $job = ($params[1] instanceof Job ? $params[1] : null); + + $span->setAttributes([ + 'messaging.message.deleted' => $job?->isDeleted(), + 'messaging.message.released' => $job?->isReleased(), + ]); + + $this->endSpan($exception); + }, + ); + } + + private function hookWorkerGetNextJob(): bool + { + return hook( + QueueWorker::class, + 'getNextJob', + pre: function (QueueWorker $worker, array $params, string $class, string $function, ?string $filename, ?int $lineno) { + /** @var \Illuminate\Contracts\Queue\Queue $connection */ + $connection = $params[0]; + $queue = $params[1]; + + $attributes = $this->buildMessageAttributes($connection, '', $queue); + + /** @psalm-suppress ArgumentTypeCoercion */ + $span = $this->instrumentation + ->tracer() + ->spanBuilder(vsprintf('%s %s', [ + $attributes[TraceAttributes::MESSAGING_DESTINATION_NAME], + TraceAttributeValues::MESSAGING_OPERATION_RECEIVE, + ])) + ->setSpanKind(SpanKind::KIND_CONSUMER) + ->setAttributes($attributes) + ->startSpan(); + + Context::storage()->attach($span->storeInContext(Context::getCurrent())); + + return $params; + }, + post: function (QueueWorker $worker, array $params, ?Job $job, ?Throwable $exception) { + $scope = Context::storage()->scope(); + if (!$scope) { + return; + } + + // Discard empty receives. + if (!$job) { + $scope->detach(); + + return; + } + + /** @var \Illuminate\Contracts\Queue\Queue $connection */ + $connection = $params[0]; + /** @var string $queue */ + $queue = $params[1]; + $attributes = $this->buildMessageAttributes($connection, $job->getRawBody(), $queue); + + $span = Span::fromContext($scope->context()); + /** @psalm-suppress PossiblyInvalidArgument */ + $span->setAttributes($attributes); + + $this->endSpan($exception); + }, + ); + } +} diff --git a/src/Instrumentation/Laravel/src/Hooks/LaravelHook.php b/src/Instrumentation/Laravel/src/Hooks/LaravelHook.php new file mode 100644 index 00000000..83b8f080 --- /dev/null +++ b/src/Instrumentation/Laravel/src/Hooks/LaravelHook.php @@ -0,0 +1,14 @@ +instrument(); + } + + return self::$instance; + } +} diff --git a/src/Instrumentation/Laravel/src/Hooks/PostHookTrait.php b/src/Instrumentation/Laravel/src/Hooks/PostHookTrait.php new file mode 100644 index 00000000..bd1e5a36 --- /dev/null +++ b/src/Instrumentation/Laravel/src/Hooks/PostHookTrait.php @@ -0,0 +1,34 @@ +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()); + } + + $span->end(); + } +} diff --git a/src/Instrumentation/Laravel/src/LaravelInstrumentation.php b/src/Instrumentation/Laravel/src/LaravelInstrumentation.php index d5e85a5c..799577f3 100644 --- a/src/Instrumentation/Laravel/src/LaravelInstrumentation.php +++ b/src/Instrumentation/Laravel/src/LaravelInstrumentation.php @@ -4,66 +4,33 @@ namespace OpenTelemetry\Contrib\Instrumentation\Laravel; -use Illuminate\Contracts\Foundation\Application; -use Illuminate\Foundation\Console\ServeCommand; use OpenTelemetry\API\Instrumentation\CachedInstrumentation; -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\RequestWatcher; -use OpenTelemetry\Contrib\Instrumentation\Laravel\Watchers\Watcher; -use function OpenTelemetry\Instrumentation\hook; -use Throwable; +use OpenTelemetry\SDK\Common\Configuration\Configuration; class LaravelInstrumentation { public const NAME = 'laravel'; - public static function registerWatchers(Application $app, Watcher $watcher) - { - $watcher->register($app); - } - public static function register(): void { $instrumentation = new CachedInstrumentation('io.opentelemetry.contrib.php.laravel'); - hook( - Application::class, - '__construct', - 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)); - self::registerWatchers($application, new RequestWatcher()); - }, - ); - - ConsoleInstrumentation::register($instrumentation); - HttpInstrumentation::register($instrumentation); - - self::developmentInstrumentation(); + Hooks\Illuminate\Console\Command::hook($instrumentation); + Hooks\Illuminate\Contracts\Console\Kernel::hook($instrumentation); + Hooks\Illuminate\Contracts\Http\Kernel::hook($instrumentation); + Hooks\Illuminate\Contracts\Queue\Queue::hook($instrumentation); + Hooks\Illuminate\Foundation\Application::hook($instrumentation); + Hooks\Illuminate\Foundation\Console\ServeCommand::hook($instrumentation); + Hooks\Illuminate\Queue\SyncQueue::hook($instrumentation); + Hooks\Illuminate\Queue\Queue::hook($instrumentation); + Hooks\Illuminate\Queue\Worker::hook($instrumentation); } - private static function developmentInstrumentation(): void + public static function shouldTraceCli(): bool { - // Allow instrumentation when using the local PHP development server. - if (class_exists(ServeCommand::class) && property_exists(ServeCommand::class, 'passthroughVariables')) { - hook( - ServeCommand::class, - 'handle', - pre: static function (ServeCommand $serveCommand, array $params, string $class, string $function, ?string $filename, ?int $lineno) { - foreach ($_ENV as $key => $value) { - if (str_starts_with($key, 'OTEL_') && !in_array($key, ServeCommand::$passthroughVariables)) { - ServeCommand::$passthroughVariables[] = $key; - } - } - }, - ); - } + return PHP_SAPI !== 'cli' || ( + class_exists(Configuration::class) + && Configuration::getBoolean('OTEL_PHP_TRACE_CLI_ENABLED', false) + ); } } diff --git a/src/Instrumentation/Laravel/src/HeadersPropagator.php b/src/Instrumentation/Laravel/src/Propagators/HeadersPropagator.php similarity index 91% rename from src/Instrumentation/Laravel/src/HeadersPropagator.php rename to src/Instrumentation/Laravel/src/Propagators/HeadersPropagator.php index f59399ba..3c646217 100644 --- a/src/Instrumentation/Laravel/src/HeadersPropagator.php +++ b/src/Instrumentation/Laravel/src/Propagators/HeadersPropagator.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace OpenTelemetry\Contrib\Instrumentation\Laravel; +namespace OpenTelemetry\Contrib\Instrumentation\Laravel\Propagators; use function assert; use Illuminate\Http\Request; diff --git a/src/Instrumentation/Laravel/src/ResponsePropagationSetter.php b/src/Instrumentation/Laravel/src/Propagators/ResponsePropagationSetter.php similarity index 88% rename from src/Instrumentation/Laravel/src/ResponsePropagationSetter.php rename to src/Instrumentation/Laravel/src/Propagators/ResponsePropagationSetter.php index 7de206f2..08f1df60 100644 --- a/src/Instrumentation/Laravel/src/ResponsePropagationSetter.php +++ b/src/Instrumentation/Laravel/src/Propagators/ResponsePropagationSetter.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace OpenTelemetry\Contrib\Instrumentation\Laravel; +namespace OpenTelemetry\Contrib\Instrumentation\Laravel\Propagators; use function assert; use Illuminate\Http\Response; diff --git a/src/Instrumentation/Laravel/src/Watchers/CacheWatcher.php b/src/Instrumentation/Laravel/src/Watchers/CacheWatcher.php index d4761f41..59e1fb71 100644 --- a/src/Instrumentation/Laravel/src/Watchers/CacheWatcher.php +++ b/src/Instrumentation/Laravel/src/Watchers/CacheWatcher.php @@ -14,7 +14,10 @@ class CacheWatcher extends Watcher { - /** @psalm-suppress UndefinedInterfaceMethod */ + /** + * @psalm-suppress UndefinedInterfaceMethod + * @suppress PhanTypeArraySuspicious + */ public function register(Application $app): void { $app['events']->listen(CacheHit::class, [$this, 'recordCacheHit']); @@ -39,7 +42,10 @@ public function recordCacheMiss(CacheMissed $event): void 'tags' => json_encode($event->tags), ]); } - /** @psalm-suppress UndefinedPropertyFetch */ + /** + * @psalm-suppress UndefinedPropertyFetch + * @suppress PhanUndeclaredProperty + */ public function recordCacheSet(KeyWritten $event): void { $ttl = property_exists($event, 'minutes') diff --git a/src/Instrumentation/Laravel/src/Watchers/ClientRequestWatcher.php b/src/Instrumentation/Laravel/src/Watchers/ClientRequestWatcher.php index 61c8cdff..855c9f57 100644 --- a/src/Instrumentation/Laravel/src/Watchers/ClientRequestWatcher.php +++ b/src/Instrumentation/Laravel/src/Watchers/ClientRequestWatcher.php @@ -29,7 +29,10 @@ public function __construct( ) { } - /** @psalm-suppress UndefinedInterfaceMethod */ + /** + * @psalm-suppress UndefinedInterfaceMethod + * @suppress PhanTypeArraySuspicious + */ public function register(Application $app): void { $app['events']->listen(RequestSending::class, [$this, 'recordRequest']); @@ -39,6 +42,7 @@ public function register(Application $app): void /** * @psalm-suppress ArgumentTypeCoercion + * @suppress PhanEmptyFQSENInCallable,PhanUndeclaredFunctionInCallable */ public function recordRequest(RequestSending $request): void { diff --git a/src/Instrumentation/Laravel/src/Watchers/ExceptionWatcher.php b/src/Instrumentation/Laravel/src/Watchers/ExceptionWatcher.php index 0bc936d8..7bbb2bdd 100644 --- a/src/Instrumentation/Laravel/src/Watchers/ExceptionWatcher.php +++ b/src/Instrumentation/Laravel/src/Watchers/ExceptionWatcher.php @@ -17,6 +17,7 @@ class ExceptionWatcher extends Watcher /** @psalm-suppress UndefinedInterfaceMethod */ public function register(Application $app): void { + /** @phan-suppress-next-line PhanTypeArraySuspicious */ $app['events']->listen(MessageLogged::class, [$this, 'recordException']); } /** diff --git a/src/Instrumentation/Laravel/src/Watchers/LogWatcher.php b/src/Instrumentation/Laravel/src/Watchers/LogWatcher.php index 370b501d..f887da83 100644 --- a/src/Instrumentation/Laravel/src/Watchers/LogWatcher.php +++ b/src/Instrumentation/Laravel/src/Watchers/LogWatcher.php @@ -14,6 +14,7 @@ class LogWatcher extends Watcher /** @psalm-suppress UndefinedInterfaceMethod */ public function register(Application $app): void { + /** @phan-suppress-next-line PhanTypeArraySuspicious */ $app['events']->listen(MessageLogged::class, [$this, 'recordLog']); } diff --git a/src/Instrumentation/Laravel/src/Watchers/QueryWatcher.php b/src/Instrumentation/Laravel/src/Watchers/QueryWatcher.php index f0c1e417..ff1c82d5 100644 --- a/src/Instrumentation/Laravel/src/Watchers/QueryWatcher.php +++ b/src/Instrumentation/Laravel/src/Watchers/QueryWatcher.php @@ -21,6 +21,7 @@ public function __construct( /** @psalm-suppress UndefinedInterfaceMethod */ public function register(Application $app): void { + /** @phan-suppress-next-line PhanTypeArraySuspicious */ $app['events']->listen(QueryExecuted::class, [$this, 'recordQuery']); } diff --git a/src/Instrumentation/Laravel/src/Watchers/RequestWatcher.php b/src/Instrumentation/Laravel/src/Watchers/RequestWatcher.php deleted file mode 100644 index 9e257029..00000000 --- a/src/Instrumentation/Laravel/src/Watchers/RequestWatcher.php +++ /dev/null @@ -1,27 +0,0 @@ -listen(RouteMatched::class, static function (RouteMatched $event): void { - /** @var SpanInterface|null $span */ - $span = $event->request->attributes->get(SpanInterface::class); - - if ($span) { - $span->updateName("{$event->request->getMethod()} /" . ltrim($event->route->uri, '/')); - $span->setAttribute(TraceAttributes::HTTP_ROUTE, $event->route->uri); - } - }); - } -} diff --git a/src/Instrumentation/Laravel/tests/Fixtures/Jobs/DummyJob.php b/src/Instrumentation/Laravel/tests/Fixtures/Jobs/DummyJob.php new file mode 100644 index 00000000..cf14062c --- /dev/null +++ b/src/Instrumentation/Laravel/tests/Fixtures/Jobs/DummyJob.php @@ -0,0 +1,24 @@ +info("Task: {$this->name}"); + } +} diff --git a/src/Instrumentation/Laravel/tests/Integration/ConsoleInstrumentationTest.php b/src/Instrumentation/Laravel/tests/Integration/Console/CommandTest.php similarity index 92% rename from src/Instrumentation/Laravel/tests/Integration/ConsoleInstrumentationTest.php rename to src/Instrumentation/Laravel/tests/Integration/Console/CommandTest.php index 16a7f984..1d46fbca 100644 --- a/src/Instrumentation/Laravel/tests/Integration/ConsoleInstrumentationTest.php +++ b/src/Instrumentation/Laravel/tests/Integration/Console/CommandTest.php @@ -2,12 +2,13 @@ declare(strict_types=1); -namespace OpenTelemetry\Tests\Contrib\Instrumentation\Laravel\Integration; +namespace OpenTelemetry\Tests\Contrib\Instrumentation\Laravel\Integration\Console; use Illuminate\Console\Command; use Illuminate\Contracts\Console\Kernel; +use OpenTelemetry\Tests\Contrib\Instrumentation\Laravel\Integration\TestCase; -class ConsoleInstrumentationTest extends TestCase +class CommandTest extends TestCase { public function test_command_tracing(): void { diff --git a/src/Instrumentation/Laravel/tests/Integration/Http/ClientTest.php b/src/Instrumentation/Laravel/tests/Integration/Http/ClientTest.php index e66c1f73..268379cf 100644 --- a/src/Instrumentation/Laravel/tests/Integration/Http/ClientTest.php +++ b/src/Instrumentation/Laravel/tests/Integration/Http/ClientTest.php @@ -15,8 +15,7 @@ class ClientTest extends TestCase { - /** @test */ - public function it_records_requests(): void + public function test_it_records_requests(): void { Http::fake([ 'ok.opentelemetry.io/*' => Http::response(status: 201), @@ -36,8 +35,7 @@ public function it_records_requests(): void self::assertEquals('ok.opentelemetry.io/foo', $span->getAttributes()->get(TraceAttributes::URL_PATH)); } - /** @test */ - public function it_records_connection_failures(): void + public function test_it_records_connection_failures(): void { Http::fake(fn (Request $request) => new RejectedPromise(new ConnectException('Failure', $request->toPsrRequest()))); diff --git a/src/Instrumentation/Laravel/tests/Integration/Queue/QueueTest.php b/src/Instrumentation/Laravel/tests/Integration/Queue/QueueTest.php new file mode 100644 index 00000000..5ad620e9 --- /dev/null +++ b/src/Instrumentation/Laravel/tests/Integration/Queue/QueueTest.php @@ -0,0 +1,149 @@ +queue = $this->app->make(Queue::class); + } + + public function test_it_handles_pushing_to_a_queue(): void + { + $this->queue->push(new DummyJob('A')); + $this->queue->push(function (LoggerInterface $logger) { + $logger->info('Logged from closure'); + }); + + $this->assertEquals('sync process', $this->storage[0]->getName()); + $this->assertEquals('Task: A', $this->storage[0]->getEvents()[0]->getName()); + + $this->assertEquals('sync process', $this->storage[1]->getName()); + $this->assertEquals('Logged from closure', $this->storage[1]->getEvents()[0]->getName()); + } + + public function test_it_can_push_a_message_with_a_delay(): void + { + $this->queue->later(15, new DummyJob('int')); + $this->queue->later(new DateInterval('PT10M'), new DummyJob('DateInterval')); + $this->queue->later(new DateTimeImmutable('2024-04-15 22:29:00.123Z'), new DummyJob('DateTime')); + + $this->assertEquals('sync create', $this->storage[1]->getName()); + $this->assertIsInt( + $this->storage[1]->getAttributes()->get('messaging.message.delivery_timestamp'), + ); + + $this->assertEquals('sync create', $this->storage[3]->getName()); + $this->assertIsInt( + $this->storage[3]->getAttributes()->get('messaging.message.delivery_timestamp'), + ); + + $this->assertEquals('sync create', $this->storage[5]->getName()); + $this->assertIsInt( + $this->storage[5]->getAttributes()->get('messaging.message.delivery_timestamp'), + ); + } + + public function test_it_can_publish_in_bulk(): void + { + $jobs = []; + for ($i = 0; $i < 10; ++$i) { + $jobs[] = new DummyJob("#{$i}"); + } + + /** @var SqsQueue|MockInterface $mockQueue */ + $mockQueue = $this->createMock(SqsQueue::class); + /** @psalm-suppress UndefinedMethod */ + $mockQueue->method('getQueue')->willReturn('dummy-queue'); + + /** @psalm-suppress PossiblyUndefinedMethod */ + $mockQueue->bulk($jobs); + + $this->assertEquals('dummy-queue publish', $this->storage[0]->getName()); + $this->assertEquals(10, $this->storage[0]->getAttributes()->get(TraceAttributes::MESSAGING_BATCH_MESSAGE_COUNT)); + } + + public function test_it_can_create_with_redis(): void + { + /** @var RedisQueue|MockInterface $mockQueue */ + $mockQueue = $this->createMock(RedisQueue::class); + /** @psalm-suppress UndefinedMethod */ + $mockQueue->method('getQueue')->willReturn('queues:default'); + /** @psalm-suppress UndefinedMethod */ + $mockQueue + ->method('getConnection') + ->willReturn($this->createMock(Connection::class)); + + /** @psalm-suppress PossiblyUndefinedMethod */ + $mockQueue->bulk([ + new DummyJob('A'), + new DummyJob('B'), + ]); + + $this->assertEquals('queues:default publish', $this->storage[0]->getName()); + $this->assertEquals(2, $this->storage[0]->getAttributes()->get(TraceAttributes::MESSAGING_BATCH_MESSAGE_COUNT)); + $this->assertEquals('redis', $this->storage[0]->getAttributes()->get(TraceAttributes::MESSAGING_SYSTEM)); + } + + public function test_it_drops_empty_receives(): void + { + $mockQueueManager = $this->createMock(QueueManager::class); + + $mockQueueManager->method('connection') + ->with('sqs') + ->willReturn($this->createMock(SqsQueue::class)); + + /** + * @psalm-suppress PossiblyNullReference + * @var Worker $worker + */ + $worker = $this->app->make(Worker::class, [ + 'manager' => $mockQueueManager, + 'isDownForMaintenance' => fn () => false, + ]); + + $receive = fn () => $worker->runNextJob('sqs', 'default', new WorkerOptions(sleep: 0)); + + for ($i = 0; $i < 1000; $i++) { + if ($i % 10 === 0) { + $this->queue->push(new DummyJob("{$i}")); + } + + $receive(); + } + + for ($i = 0; $i < 2; $i++) { + $this->queue->push(new DummyJob('More work')); + $receive(); + } + + /** @psalm-suppress PossiblyInvalidMethodCall */ + $this->assertEquals(102, $this->storage->count()); + + $this->assertEquals('Task: 500', $this->storage[50]->getEvents()[0]->getName()); + $this->assertEquals('Task: More work', $this->storage[100]->getEvents()[0]->getName()); + } +}