diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 7fdc60cfd..c9a6bc9d5 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -57,10 +57,10 @@ updates: # Maintain dependencies for all packages - package-ecosystem: "composer" directories: - - "/examples/aws/AwsClientApp" - "/examples/instrumentation/Wordpress" - "/src/AutoInstrumentationInstaller" - "/src/Aws" + - "/src/Aws/examples/AwsClientApp" - "/src/Context/Swoole" - "/src/Exporter/Instana" - "/src/Instrumentation/AwsSdk" diff --git a/src/Aws/composer.json b/src/Aws/composer.json index ca120da03..d87ae1aee 100644 --- a/src/Aws/composer.json +++ b/src/Aws/composer.json @@ -14,8 +14,11 @@ "prefer-stable": true, "require": { "php": "^8.1", + "bref/bref": "^2.4", "open-telemetry/api": "^1.0", "open-telemetry/sdk": "^1.0", + "open-telemetry/exporter-otlp": "^1.3", + "open-telemetry/sem-conv": "^1.32", "aws/aws-sdk-php": "^3.232" }, "require-dev": { @@ -45,8 +48,9 @@ "sort-packages": true, "allow-plugins": { "composer/package-versions-deprecated": true, + "php-http/discovery": false, "symfony/runtime": true, - "php-http/discovery": false + "tbachert/spi": true } } } diff --git a/src/Aws/src/Lambda/AwsLambdaWrapper.php b/src/Aws/src/Lambda/AwsLambdaWrapper.php new file mode 100644 index 000000000..7e701823e --- /dev/null +++ b/src/Aws/src/Lambda/AwsLambdaWrapper.php @@ -0,0 +1,186 @@ +merge($lambdaDetector->getResource()); + + if (getenv(self::OTEL_SERVICE_NAME_ENV) == null) { + $service_name_resource = ResourceInfo::create(Attributes::create([ + TraceAttributes::SERVICE_NAME => getenv(self::LAMBDA_NAME_ENV), + ])); + $resource = $resource->merge($service_name_resource); + } + + // 1) Configure OTLP/HTTP exporter + $transportFactory = new OtlpHttpTransportFactory(); + $transport = $transportFactory->create( + getenv(self::OTEL_EXPORTER_ENDPOINT_ENV) + ?: self::DEFAULT_OTLP_EXPORTER_ENDPOINT, + 'application/x-protobuf' + ); + $exporter = new OtlpExporter($transport); + + $spanProcessor = new SimpleSpanProcessor($exporter); + $xrayIdGenerator = new IdGenerator(); + $tracerProvider = new TracerProvider($spanProcessor, null, $resource, null, $xrayIdGenerator); + + $this->tracer = $tracerProvider->getTracer('php-lambda'); + + register_shutdown_function(function () use ($tracerProvider): void { + $tracerProvider->shutdown(); + }); + + } + + /** @psalm-suppress PossiblyUnusedMethod */ + public function getTracer(): TracerInterface + { + return $this->tracer; + } + + /** @psalm-suppress PossiblyUnusedMethod */ + public function setTracer(TracerInterface $newTracer): void + { + $this->tracer = $newTracer; + } + + /** @psalm-suppress PossiblyUnusedMethod + * @psalm-suppress ArgumentTypeCoercion + * @psalm-suppress PossiblyFalseArgument + */ + public function WrapHandler(callable $handler): callable + { + return function (array $event, Context $context) use ($handler): array { + + $jsonContext = getenv(self::LAMBDA_INVOCATION_CONTEXT_ENV) ?: ''; + + $lambdaContext = json_decode($jsonContext, true, 512, JSON_THROW_ON_ERROR); + + $awsRequestId = $lambdaContext['awsRequestId'] ?? null; + $invokedFunctionArn = $lambdaContext['invokedFunctionArn'] ?? null; + $traceId = $lambdaContext['traceId'] ?? null; + + $propagator = new XrayPropagator(); + $carrier = [ + XrayPropagator::AWSXRAY_TRACE_ID_HEADER => $traceId, + ]; + $parentCtx = $propagator->extract($carrier); + + $lambdaName = getenv(self::LAMBDA_NAME_ENV); + + $lambdaSpanAttributes = Attributes::create([ + TraceAttributes::FAAS_TRIGGER => self::isHttpRequest($event) ? 'http' : 'other', + TraceAttributes::FAAS_COLDSTART => $this->isColdStart, + TraceAttributes::FAAS_NAME => $lambdaName, + TraceAttributes::FAAS_INVOCATION_ID => $awsRequestId, + TraceAttributes::CLOUD_RESOURCE_ID => self::getCloudResourceId($invokedFunctionArn), + TraceAttributes::CLOUD_ACCOUNT_ID => self::getAccountId($invokedFunctionArn), + ]); + + $this->isColdStart = false; + + // Start a root span for the Lambda invocation + $rootSpan = $this->tracer + ->spanBuilder($lambdaName) + ->setParent($parentCtx) + ->setAttributes($lambdaSpanAttributes) + ->setSpanKind(SpanKind::KIND_SERVER) + ->startSpan(); + $rootScope = $rootSpan->activate(); + + try { + return $handler($event, $context); + } finally { + $rootSpan->end(); + $rootScope->detach(); + } + }; + } + + private static function getAccountId(?string $functionArn): ?string + { + if (empty($functionArn)) { + return null; + } + + $segments = explode(':', $functionArn); + + return isset($segments[4]) ? $segments[4] : null; + } + + private static function getCloudResourceId(?string $functionArn): ?string + { + if (empty($functionArn)) { + return null; + } + + // According to cloud.resource_id description https://github.com/open-telemetry/semantic-conventions/blob/v1.32.0/docs/resource/faas.md?plain=1#L59-L63 + // the 8th part of arn (function version or alias, see https://docs.aws.amazon.com/lambda/latest/dg/lambda-api-permissions-ref.html) + // should not be included into cloud.resource_id + $segments = explode(':', $functionArn); + if (count($segments) >= 8) { + return implode(':', array_slice($segments, 0, 7)); + } + + return $functionArn; + } + + private static function isHttpRequest(array $event): bool + { + try { + /** @phan-suppress-next-line PhanNoopNew */ + new HttpRequestEvent($event); + } catch (\Throwable $th) { + return false; + } + + return true; + } + +} diff --git a/src/Aws/src/Lambda/README.md b/src/Aws/src/Lambda/README.md new file mode 100644 index 000000000..995c3634f --- /dev/null +++ b/src/Aws/src/Lambda/README.md @@ -0,0 +1,39 @@ +# AWS Lambda Instrumentation for OpenTelemetry PHP +This package supports manual instrumentation for the AWS Lambda functions written in PHP. PHP Lambda functions can be deployed using [Bref](https://bref.sh/) which this package primary depends on when doing instrumentation. + + +## Using the AWS Lambda Instrumentation with AWS Lambda Functions +Below is a example on how to setup AWS Lambda Instrumentation: + +1. Follow the steps in this [README](../../README.md) to install the AWS Contrib Dependency. +2. Follow the example below to apply the wrapper and get your handler function instrumented: +```php +getTracer(); + +// Alternatively, you can create your own tracer and then call $wrapper->setTracer($customTracer) + +// Your PHP Handler Function and logic. +$handlerFunction = function (array $event, Context $context) use ($tracer): array { + // .... handler code using $tracer to manually instrument spans. + return [ + 'statusCode' => 404, + 'headers' => ['Content-Type' => 'text/plain'], + 'body' => 'Not Found', + ]; +}; + +// The WrapHandler Function is where you pass the original function and it gets instrumented +return $wrapper->WrapHandler($handlerFunction); +``` \ No newline at end of file diff --git a/src/Aws/tests/Unit/Lambda/AwsLambdaWrapperTest.php b/src/Aws/tests/Unit/Lambda/AwsLambdaWrapperTest.php new file mode 100644 index 000000000..68cad4d93 --- /dev/null +++ b/src/Aws/tests/Unit/Lambda/AwsLambdaWrapperTest.php @@ -0,0 +1,163 @@ +getProperty('instance'); + /** @psalm-suppress UnusedMethodCall */ + $instanceProp->setAccessible(true); + $instanceProp->setValue(null, null); + } + + public function testWrapHandlerEmitsRootSpanWithCorrectAttributes(): void + { + // 1) Set up the expected Lambda environment + putenv('AWS_LAMBDA_FUNCTION_NAME=my-lambda'); + $lambdaContext = [ + 'awsRequestId' => 'request-1', + 'invokedFunctionArn' => 'arn:aws:lambda:us-east-1:123:function:myFunc:PROD', + 'traceId' => '', + ]; + /** @psalm-suppress PossiblyFalseOperand */ + putenv('LAMBDA_INVOCATION_CONTEXT=' . json_encode($lambdaContext)); + + // 2) Build an in-memory exporter and tracer provider + $exporter = new InMemoryExporter(); + $spanProcessor = new SimpleSpanProcessor($exporter); + + $defaultResource = ResourceInfoFactory::defaultResource(); + $lambdaResource = (new LambdaDetector())->getResource(); + $resource = $defaultResource->merge($lambdaResource); + + $tracerProvider = new TracerProvider( + $spanProcessor, + null, + $resource, + null, + new IdGenerator() + ); + $tracer = $tracerProvider->getTracer('php-lambda'); + + // 3) Inject our test tracer into the wrapper + $wrapper = AwsLambdaWrapper::getInstance(); + $wrapper->setTracer($tracer); + + // 4) Wrap a no-op handler + $wrapped = $wrapper->WrapHandler( + /** @psalm-suppress UnusedClosureParam */ + function (array $event, Context $ctx): array { + return [ + 'statusCode' => 200, + 'headers' => ['Content-Type' => 'text/plain'], + 'body' => 'OK', + ]; + } + ); + + // 5) Prepare a faux HTTP event and a Context instance + $event = [ + 'rawPath' => '/outgoing-http-call', + 'headers' => [], + ]; + + // Create a Bref\Context\Context object *without* invoking its constructor + $rc = new \ReflectionClass(BrefContext::class); + $context = $rc->newInstanceWithoutConstructor(); + + // 6) Invoke the wrapped handler + $response = $wrapped($event, $context); + $this->assertSame(200, $response['statusCode']); + $this->assertSame('OK', $response['body']); + + // 7) Flush and collect spans + $tracerProvider->shutdown(); + $spans = $exporter->getSpans(); + $this->assertCount(1, $spans, 'Exactly one root span should be emitted'); + + $rootSpan = $spans[0]; + + // 8) Assert the span’s name and semantic attributes + $this->assertSame('my-lambda', $rootSpan->getName()); + + $attrs = $rootSpan->getAttributes(); + $this->assertSame('other', $attrs->get(TraceAttributes::FAAS_TRIGGER)); + $this->assertTrue($attrs->get(TraceAttributes::FAAS_COLDSTART)); + $this->assertSame('my-lambda', $attrs->get(TraceAttributes::FAAS_NAME)); + $this->assertSame('request-1', $attrs->get(TraceAttributes::FAAS_INVOCATION_ID)); + $this->assertSame('arn:aws:lambda:us-east-1:123:function:myFunc', $attrs->get(TraceAttributes::CLOUD_RESOURCE_ID)); + $this->assertSame('123', $attrs->get(TraceAttributes::CLOUD_ACCOUNT_ID)); + } + + public function testGetAccountIdExtractsCorrectly(): void + { + $refClass = new ReflectionClass(AwsLambdaWrapper::class); + $method = $refClass->getMethod('getAccountId'); + /** @psalm-suppress UnusedMethodCall */ + $method->setAccessible(true); + + // null or empty input returns null + $this->assertNull($method->invoke(null, null)); + $this->assertNull($method->invoke(null, '')); + + // valid ARN yields the 5th segment + $arn = 'arn:aws:lambda:us-west-2:123456789012:function:myFunc'; + $this->assertSame('123456789012', $method->invoke(null, $arn)); + + // too-short ARN yields null + $this->assertNull($method->invoke(null, 'arn:aws:lambda:us-west-2')); + } + + public function testGetCloudResourceIdExtractsCorrectly(): void + { + $refClass = new ReflectionClass(AwsLambdaWrapper::class); + $method = $refClass->getMethod('getCloudResourceId'); + /** @psalm-suppress UnusedMethodCall */ + $method->setAccessible(true); + + // null or empty input returns null + $this->assertNull($method->invoke(null, null)); + $this->assertNull($method->invoke(null, '')); + + // ARN with version/alias is trimmed at the 7th segment + $fullArn = 'arn:aws:lambda:us-east-1:123:function:myFunc:PROD'; + $expected1 = 'arn:aws:lambda:us-east-1:123:function:myFunc'; + $this->assertSame($expected1, $method->invoke(null, $fullArn)); + + // ARN without version stays intact + $shortArn = 'arn:aws:lambda:us-east-1:123:function:myFunc'; + $this->assertSame($shortArn, $method->invoke(null, $shortArn)); + } + + public function testTracerIsConfigurable(): void + { + $wrapper = AwsLambdaWrapper::getInstance(); + $original = $wrapper->getTracer(); + $this->assertInstanceOf(TracerInterface::class, $original); + + $mockTracer = $this->createMock(TracerInterface::class); + $wrapper->setTracer($mockTracer); + $this->assertSame($mockTracer, $wrapper->getTracer()); + } +}