diff --git a/src/Aws/phpunit.xml.dist b/src/Aws/phpunit.xml.dist index 44d976f6f..d85488537 100644 --- a/src/Aws/phpunit.xml.dist +++ b/src/Aws/phpunit.xml.dist @@ -39,6 +39,9 @@ tests/Unit + + tests/Integration + diff --git a/src/Aws/src/AwsSdkInstrumentation.php b/src/Aws/src/AwsSdkInstrumentation.php index 4c45582b5..0a213d3f6 100644 --- a/src/Aws/src/AwsSdkInstrumentation.php +++ b/src/Aws/src/AwsSdkInstrumentation.php @@ -8,12 +8,10 @@ use Aws\ResultInterface; use OpenTelemetry\API\Instrumentation\InstrumentationInterface; use OpenTelemetry\API\Instrumentation\InstrumentationTrait; -use OpenTelemetry\API\Trace\SpanInterface; use OpenTelemetry\API\Trace\SpanKind; use OpenTelemetry\API\Trace\TracerInterface; use OpenTelemetry\API\Trace\TracerProviderInterface; use OpenTelemetry\Context\Propagation\TextMapPropagatorInterface; -use OpenTelemetry\Context\ScopeInterface; /** * @experimental @@ -25,13 +23,12 @@ class AwsSdkInstrumentation implements InstrumentationInterface public const NAME = 'AWS SDK Instrumentation'; public const VERSION = '0.0.1'; public const SPAN_KIND = SpanKind::KIND_CLIENT; - private TextMapPropagatorInterface $propagator; - private TracerProviderInterface $tracerProvider; - private $clients = [] ; - private string $clientName; - private string $region; - private SpanInterface $span; - private ScopeInterface $scope; + + private array $clients = []; + + private array $instrumentedClients = []; + + private array $spanStorage = []; public function getName(): string { @@ -79,61 +76,70 @@ public function getTracer(): TracerInterface } /** @psalm-api */ - public function instrumentClients($clientsArray) : void + public function instrumentClients($clientsArray): void { $this->clients = $clientsArray; } - /** @psalm-suppress ArgumentTypeCoercion */ public function activate(): bool { try { - $middleware = Middleware::tap(function ($cmd, $_req) { - $tracer = $this->getTracer(); - $propagator = $this->getPropagator(); - - $carrier = []; - /** @phan-suppress-next-line PhanTypeMismatchArgument */ - $this->span = $tracer->spanBuilder($this->clientName)->setSpanKind(AwsSdkInstrumentation::SPAN_KIND)->startSpan(); - $this->scope = $this->span->activate(); - - $propagator->inject($carrier); - - /** @psalm-suppress PossiblyInvalidArgument */ - $this->span->setAttributes([ - 'rpc.method' => $cmd->getName(), - 'rpc.service' => $this->clientName, - 'rpc.system' => 'aws-api', - 'aws.region' => $this->region, - ]); - }); - - /** @psalm-suppress PossiblyInvalidArgument */ - $end_middleware = Middleware::mapResult(function (ResultInterface $result) { - /** - * Some AWS SDK Funtions, such as S3Client->getObjectUrl() do not actually perform on the wire comms - * with AWS Servers, and therefore do not return with a populated AWS\Result object with valid @metadata - * Check for the presence of @metadata before extracting status code as these calls are still - * instrumented. - */ - if (isset($result['@metadata'])) { - $this->span->setAttributes([ - 'http.status_code' => $result['@metadata']['statusCode'], //@phan-suppress-current-line PhanTypeMismatchDimFetch - ]); + foreach ($this->clients as $client) { + $hash = spl_object_hash($client); + if (isset($this->instrumentedClients[$hash])) { + continue; } - $this->span->end(); - $this->scope->detach(); + $clientName = $client->getApi()->getServiceName(); + $region = $client->getRegion(); - return $result; - }); + $client->getHandlerList()->prependInit(Middleware::tap(function ($cmd, $_req) use ($clientName, $region, $hash) { + $tracer = $this->getTracer(); + $propagator = $this->getPropagator(); - foreach ($this->clients as $client) { - $this->clientName = $client->getApi()->getServiceName(); - $this->region = $client->getRegion(); + $carrier = []; + /** @phan-suppress-next-line PhanTypeMismatchArgument */ + $span = $tracer->spanBuilder($clientName)->setSpanKind(AwsSdkInstrumentation::SPAN_KIND)->startSpan(); + $scope = $span->activate(); + $this->spanStorage[$hash] = [$span, $scope]; - $client->getHandlerList()->prependInit($middleware, 'instrumentation'); - $client->getHandlerList()->appendSign($end_middleware, 'end_instrumentation'); + $propagator->inject($carrier); + + /** @psalm-suppress PossiblyInvalidArgument */ + $span->setAttributes([ + 'rpc.method' => $cmd->getName(), + 'rpc.service' => $clientName, + 'rpc.system' => 'aws-api', + 'aws.region' => $region, + ]); + }), 'instrumentation'); + + $client->getHandlerList()->appendSign(Middleware::mapResult(function (ResultInterface $result) use ($hash) { + if (empty($this->spanStorage[$hash])) { + return $result; + } + [$span, $scope] = $this->spanStorage[$hash]; + unset($this->spanStorage[$hash]); + + /* + * Some AWS SDK Functions, such as S3Client->getObjectUrl() do not actually perform on the wire comms + * with AWS Servers, and therefore do not return with a populated AWS\Result object with valid @metadata + * Check for the presence of @metadata before extracting status code as these calls are still + * instrumented. + */ + if (isset($result['@metadata'])) { + $span->setAttributes([ + 'http.status_code' => $result['@metadata']['statusCode'], // @phan-suppress-current-line PhanTypeMismatchDimFetch + ]); + } + + $span->end(); + $scope->detach(); + + return $result; + }), 'end_instrumentation'); + + $this->instrumentedClients[$hash] = 1; } } catch (\Throwable $e) { return false; diff --git a/src/Aws/tests/Integration/AwsSdkInstrumentationTest.php b/src/Aws/tests/Integration/AwsSdkInstrumentationTest.php new file mode 100644 index 000000000..f31280b08 --- /dev/null +++ b/src/Aws/tests/Integration/AwsSdkInstrumentationTest.php @@ -0,0 +1,216 @@ +awsSdkInstrumentation = new AwsSdkInstrumentation(); + } + + public function testProperClientNameAndRegionIsPassedToSpanForSingleClientCall() + { + /** @var SqsClient $sqsClient */ + $sqsClient = $this->getTestClient('SQS', ['region' => 'eu-west-1']); + /** @var S3Client $s3Client */ + $s3Client = $this->getTestClient('S3', ['region' => 'us-east-1']); + $this->addMockResults($s3Client, [[]]); + /** @var EventBridgeClient $eventBridgeClient */ + $eventBridgeClient = $this->getTestClient('EventBridge', ['region' => 'ap-southeast-2']); + + $spanProcessor = new CollectingSpanProcessor(); + $this->awsSdkInstrumentation->instrumentClients([$sqsClient, $s3Client, $eventBridgeClient]); + $this->awsSdkInstrumentation->setPropagator(new Propagator()); + $this->awsSdkInstrumentation->setTracerProvider(new TracerProvider([$spanProcessor])); + $this->awsSdkInstrumentation->init(); + $this->awsSdkInstrumentation->activate(); + + $s3Client->listBuckets(); + + $collectedSpans = $spanProcessor->getCollectedSpans(); + $this->assertCount(1, $collectedSpans); + + /** @var ReadWriteSpanInterface $span */ + $span = reset($collectedSpans); + $this->assertTrue($span->hasEnded()); + + $attributes = $span->toSpanData()->getAttributes()->toArray(); + $this->assertArrayHasKey('rpc.service', $attributes); + $this->assertSame('s3', $attributes['rpc.service']); + $this->assertArrayHasKey('aws.region', $attributes); + $this->assertSame('us-east-1', $attributes['aws.region']); + } + + public function testProperClientNameAndRegionIsPassedToSpanForDoubleCallToSameClient() + { + /** @var SqsClient $sqsClient */ + $sqsClient = $this->getTestClient('SQS', ['region' => 'eu-west-1']); + /** @var S3Client $s3Client */ + $s3Client = $this->getTestClient('S3', ['region' => 'us-east-1']); + $this->addMockResults($s3Client, [[], []]); + /** @var EventBridgeClient $eventBridgeClient */ + $eventBridgeClient = $this->getTestClient('EventBridge', ['region' => 'ap-southeast-2']); + + $spanProcessor = new CollectingSpanProcessor(); + $this->awsSdkInstrumentation->instrumentClients([$sqsClient, $s3Client, $eventBridgeClient]); + $this->awsSdkInstrumentation->setPropagator(new Propagator()); + $this->awsSdkInstrumentation->setTracerProvider(new TracerProvider([$spanProcessor])); + $this->awsSdkInstrumentation->init(); + $this->awsSdkInstrumentation->activate(); + + $s3Client->listBuckets(); + $s3Client->listObjects(['Bucket' => 'foo']); + + $collectedSpans = $spanProcessor->getCollectedSpans(); + $this->assertCount(2, $collectedSpans); + + /** @var ReadWriteSpanInterface $span */ + foreach ($collectedSpans as $span) { + $this->assertTrue($span->hasEnded()); + $attributes = $span->toSpanData()->getAttributes()->toArray(); + $this->assertArrayHasKey('rpc.service', $attributes); + $this->assertSame('s3', $attributes['rpc.service']); + $this->assertArrayHasKey('aws.region', $attributes); + $this->assertSame('us-east-1', $attributes['aws.region']); + } + } + + public function testProperClientNameAndRegionIsPassedToSpanForDoubleCallToDifferentClients() + { + /** @var SqsClient $sqsClient */ + $sqsClient = $this->getTestClient('SQS', ['region' => 'eu-west-1']); + /** @var S3Client $s3Client */ + $s3Client = $this->getTestClient('S3', ['region' => 'us-east-1']); + $this->addMockResults($s3Client, [[]]); + /** @var EventBridgeClient $eventBridgeClient */ + $eventBridgeClient = $this->getTestClient('EventBridge', ['region' => 'ap-southeast-2']); + $this->addMockResults($eventBridgeClient, [[]]); + + $spanProcessor = new CollectingSpanProcessor(); + $this->awsSdkInstrumentation->instrumentClients([$sqsClient, $s3Client, $eventBridgeClient]); + $this->awsSdkInstrumentation->setPropagator(new Propagator()); + $this->awsSdkInstrumentation->setTracerProvider(new TracerProvider([$spanProcessor])); + $this->awsSdkInstrumentation->init(); + $this->awsSdkInstrumentation->activate(); + + $eventBridgeClient->putEvents([ + 'Entries' => [ + [ + 'Version' => 1, + 'EventBusName' => 'foo', + 'Source' => 'bar', + 'DetailType' => 'type', + 'Detail' => '{}', + ], + ], + ]); + $s3Client->listBuckets(); + + $collectedSpans = $spanProcessor->getCollectedSpans(); + $this->assertCount(2, $collectedSpans); + + /** @var ReadWriteSpanInterface $span */ + $span = array_pop($collectedSpans); + $this->assertTrue($span->hasEnded()); + $attributes = $span->toSpanData()->getAttributes()->toArray(); + $this->assertArrayHasKey('rpc.service', $attributes); + $this->assertSame('s3', $attributes['rpc.service']); + $this->assertArrayHasKey('aws.region', $attributes); + $this->assertSame('us-east-1', $attributes['aws.region']); + + /** @var ReadWriteSpanInterface $span */ + $span = array_pop($collectedSpans); + $this->assertTrue($span->hasEnded()); + $attributes = $span->toSpanData()->getAttributes()->toArray(); + $this->assertArrayHasKey('rpc.service', $attributes); + $this->assertSame('eventbridge', $attributes['rpc.service']); + $this->assertArrayHasKey('aws.region', $attributes); + $this->assertSame('ap-southeast-2', $attributes['aws.region']); + } + + public function testSpansFromDifferentClientsAreNotOverwritingOneAnother() + { + try { + /** @var SqsClient $sqsClient */ + $sqsClient = $this->getTestClient('SQS', ['region' => 'eu-west-1']); + $this->addMockResults($sqsClient, [[]]); + /** @var S3Client $s3Client */ + $s3Client = $this->getTestClient('S3', ['region' => 'us-east-1']); + $this->addMockResults($s3Client, [[]]); + + $spanProcessor = new CollectingSpanProcessor(); + $this->awsSdkInstrumentation->instrumentClients([$sqsClient, $s3Client]); + $this->awsSdkInstrumentation->setPropagator(new Propagator()); + $this->awsSdkInstrumentation->setTracerProvider(new TracerProvider([$spanProcessor])); + $this->awsSdkInstrumentation->init(); + $this->awsSdkInstrumentation->activate(); + + $sqsClient->listQueuesAsync(); + $s3Client->listBucketsAsync(); + + $collectedSpans = $spanProcessor->getCollectedSpans(); + $this->assertCount(2, $collectedSpans); + + /** @var ReadWriteSpanInterface $span */ + $span = array_shift($collectedSpans); + $attributes = $span->toSpanData()->getAttributes()->toArray(); + $this->assertArrayHasKey('rpc.service', $attributes); + $this->assertSame('sqs', $attributes['rpc.service']); + + /** @var ReadWriteSpanInterface $span */ + $span = array_shift($collectedSpans); + $attributes = $span->toSpanData()->getAttributes()->toArray(); + $this->assertArrayHasKey('rpc.service', $attributes); + $this->assertSame('s3', $attributes['rpc.service']); + } catch (\Throwable $throwable) { + /** @phpstan-ignore-next-line */ + $this->assertFalse(true, sprintf('Exception %s occurred: %s', get_class($throwable), $throwable->getMessage())); + } + } + + public function testPreventsRepeatedInstrumentationOfSameClient() + { + $clients = [ + 'SQS' => $sqsClient = $this->getTestClient('SQS', ['region' => 'eu-west-1']), + 'S3' => $s3Client = $this->getTestClient('S3', ['region' => 'us-east-1']), + 'EventBridge' => $eventBridgeClient = $this->getTestClient('EventBridge', ['region' => 'ap-southeast-2']), + ]; + + $preInstrumentationHandlersCount = array_map(static fn (AwsClientInterface $client) => $client->getHandlerList()->count(), $clients); + + $this->awsSdkInstrumentation->instrumentClients([$sqsClient, $eventBridgeClient]); + $this->awsSdkInstrumentation->init(); + $this->awsSdkInstrumentation->activate(); + + $this->awsSdkInstrumentation->instrumentClients([$s3Client, $eventBridgeClient]); + $this->awsSdkInstrumentation->init(); + $this->awsSdkInstrumentation->activate(); + + foreach ($clients as $name => $client) { + $this->assertSame( + $preInstrumentationHandlersCount[$name] + self::HANDLERS_PER_ACTIVATION, + $client->getHandlerList()->count(), + sprintf('Failed asserting that %s client was instrumented once', $name) + ); + } + } +} diff --git a/src/Aws/tests/Integration/CollectingSpanProcessor.php b/src/Aws/tests/Integration/CollectingSpanProcessor.php new file mode 100644 index 000000000..de35d0269 --- /dev/null +++ b/src/Aws/tests/Integration/CollectingSpanProcessor.php @@ -0,0 +1,41 @@ +collectedSpans[$span->getContext()->getSpanId()] = $span; + } + + public function onEnd(ReadableSpanInterface $span): void + { + $this->collectedSpans[$span->getContext()->getSpanId()] = $span; + } + + public function forceFlush(?CancellationInterface $cancellation = null): bool + { + return true; + } + + public function shutdown(?CancellationInterface $cancellation = null): bool + { + return true; + } + + public function getCollectedSpans(): array + { + return $this->collectedSpans; + } +} diff --git a/src/Aws/tests/Integration/UsesServiceTrait.php b/src/Aws/tests/Integration/UsesServiceTrait.php new file mode 100644 index 000000000..a7ef542a4 --- /dev/null +++ b/src/Aws/tests/Integration/UsesServiceTrait.php @@ -0,0 +1,60 @@ + 'us-east-1', + 'version' => 'latest', + 'credentials' => false, + 'retries' => 0, + ]); + } + + /** + * Creates an instance of a service client for a test + */ + private function getTestClient(string $service, array $args = []): AwsClientInterface + { + $this->_mock_handler = new MockHandler([]); + + return $this->getTestSdk($args)->createClient($service); + } + + /** + * Queues up mock Result objects for a client + * + * @param Result[]|array[] $results + */ + private function addMockResults( + AwsClientInterface $client, + array $results, + ?callable $onFulfilled = null, + ?callable $onRejected = null + ): void { + foreach ($results as &$res) { + if (is_array($res)) { + $res = new Result($res); + } + } + unset($res); + + $this->_mock_handler = new MockHandler($results, $onFulfilled, $onRejected); + $client->getHandlerList()->setHandler($this->_mock_handler); + } +}