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);
+ }
+}